├── .editorconfig
├── .env.example
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── LICENSE
├── README.md
├── bypass-captcha.config.example.ts
├── install.js
├── package.json
├── playwright.config.example.ts
├── tests
├── auth.setup.ts
├── base
│ ├── account.spec.ts
│ ├── cart.spec.ts
│ ├── category.spec.ts
│ ├── checkout.spec.ts
│ ├── compare.spec.ts
│ ├── config
│ │ ├── element-identifiers
│ │ │ └── element-identifiers.json
│ │ ├── input-values
│ │ │ └── input-values.json
│ │ ├── outcome-markers
│ │ │ └── outcome-markers.json
│ │ ├── slugs.json
│ │ └── test-toggles.example.json
│ ├── contact.spec.ts
│ ├── fixtures
│ │ ├── account.page.ts
│ │ ├── base.page.ts
│ │ ├── cart.page.ts
│ │ ├── category.page.ts
│ │ ├── checkout.page.ts
│ │ ├── compare.page.ts
│ │ ├── contact.page.ts
│ │ ├── home.page.ts
│ │ ├── login.page.ts
│ │ ├── magentoAdmin.page.ts
│ │ ├── mainmenu.page.ts
│ │ ├── minicart.page.ts
│ │ ├── newsletter.page.ts
│ │ ├── product.page.ts
│ │ └── register.page.ts
│ ├── healthcheck.spec.ts
│ ├── home.spec.ts
│ ├── login.spec.ts
│ ├── mainmenu.spec.ts
│ ├── minicart.spec.ts
│ ├── product.spec.ts
│ ├── register.spec.ts
│ ├── setup.spec.ts
│ └── types
│ │ └── magewire.d.ts
└── custom
│ └── config
│ └── test-toggles.example.json
└── translate-json.js
/.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_STAGING_URL=https://hyva-demo.elgentos.io/
4 |
5 | MAGENTO_ADMIN_SLUG=
6 | MAGENTO_ADMIN_USERNAME=
7 | MAGENTO_ADMIN_PASSWORD=
8 |
9 | MAGENTO_NEW_ACCOUNT_PASSWORD=
10 | MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM=
11 | MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX=
12 | MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT=
13 | MAGENTO_EXISTING_ACCOUNT_PASSWORD=
14 | MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD=
15 |
16 | MAGENTO_COUPON_CODE_CHROMIUM=
17 | MAGENTO_COUPON_CODE_FIREFOX=
18 | MAGENTO_COUPON_CODE_WEBKIT=
19 |
20 | CAPTCHA_BYPASS=
21 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Magento 2 BDD E2E Testing Suite Self Test
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build-and-selftest:
10 | runs-on: windows-latest
11 |
12 | env:
13 | PLAYWRIGHT_BASE_URL: https://hyva-demo.elgentos.io/
14 | PLAYWRIGHT_PRODUCTION_URL: https://hyva-demo.elgentos.io/
15 | PLAYWRIGHT_STAGING_URL: https://hyva-demo.elgentos.io/
16 |
17 | MAGENTO_ADMIN_SLUG: ${{ secrets.MAGENTO_ADMIN_SLUG }}
18 | MAGENTO_ADMIN_USERNAME: ${{ secrets.MAGENTO_ADMIN_USERNAME }}
19 | MAGENTO_ADMIN_PASSWORD: ${{ secrets.MAGENTO_ADMIN_PASSWORD }}
20 |
21 | MAGENTO_NEW_ACCOUNT_PASSWORD: ${{ secrets.MAGENTO_NEW_ACCOUNT_PASSWORD }}
22 | MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM }}
23 | MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX }}
24 | MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT }}
25 | MAGENTO_EXISTING_ACCOUNT_PASSWORD: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_PASSWORD }}
26 | MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD }}
27 |
28 | MAGENTO_COUPON_CODE_CHROMIUM: ${{ secrets.MAGENTO_COUPON_CODE_CHROMIUM }}
29 | MAGENTO_COUPON_CODE_FIREFOX: ${{ secrets.MAGENTO_COUPON_CODE_FIREFOX }}
30 | MAGENTO_COUPON_CODE_WEBKIT: ${{ secrets.MAGENTO_COUPON_CODE_WEBKIT }}
31 |
32 | CAPTCHA_BYPASS: true
33 |
34 | steps:
35 | - name: Checkout repository
36 | uses: actions/checkout@v3
37 |
38 | - name: Set up Node.js
39 | uses: actions/setup-node@v3
40 | with:
41 | node-version: 16
42 |
43 | - name: Install dependencies
44 | run: npm install
45 |
46 | - name: Install Playwright browsers
47 | run: npx playwright install --with-deps
48 |
49 | - name: Copy config files
50 | run: |
51 | cp playwright.config.example.ts playwright.config.ts
52 | cp bypass-captcha.config.example.ts bypass-captcha.config.ts
53 | cp tests/base/config/test-toggles.example.json tests/base/config/test-toggles.json
54 |
55 | - name: Run Playwright setup test
56 | run: npx playwright test --reporter=line --workers=4 tests/base/setup.spec.ts
57 | env:
58 | CI: true
59 |
60 | - name: Run Playwright tests
61 | run: npx playwright test --workers=4 --grep-invert "@setup" --max-failures=1
62 | env:
63 | CI: true
64 | - uses: actions/upload-artifact@v4
65 | if: ${{ !cancelled() }}
66 | with:
67 | name: playwright-report
68 | path: playwright-report/
69 | retention-days: 30
70 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | node_modules/
3 | /test-results/
4 | /auth-storage/*
5 | /playwright-report/
6 | /blob-report/
7 | /playwright/.cache/
8 | playwright.config.ts
9 | bypass-captcha.config.ts
10 | package-lock.json
11 | .env
12 | .vscode
13 | /tests/.auth/
14 | /tests/base/config/test-toggles.json
15 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Magento 2 BDD E2E testing suite
2 | A Playwright End-To-End (E2E) testing suite for Magento 2 that helps you find (potential) issues on your webshop.
3 |
4 | Or with more jargon: a Behavior Driven Development (BDD) End-To-End (E2E) testing suite for Magento 2 using Gherkin syntax in JSDoc and Playwright.
5 |
6 | ## Table of Contents
7 |
8 | - [Introduction](#introduction)
9 | - [Why BDD and Gherkin in JSDoc for Magento 2](#why-bdd-and-gherkin-in-jsdoc-for-magento-2)
10 | - [Features](#features)
11 | - [Getting Started](#getting-started)
12 | - [Prerequisites](#prerequisites)
13 | - [Installation](#installation)
14 | - [Before you run](#before-you-run)
15 | - [How to use](#how-to-use)
16 | - [Running tests](#running-tests)
17 | - [Skipping specific tests](#skipping-specific-tests)
18 | - [Tags and Annotations](#tags-and-annotations)
19 | - [Examples](#examples)
20 | - [Contributing](#contributing)
21 | - [Writing tests](#writing-tests)
22 | - [License](#license)
23 | - [Contact](#contact)
24 | - [Known issues](#known-issues)
25 |
26 | ## Introduction
27 | Welcome to the Magento 2 BDD E2E Testing Suite! This project, referred to as “Testing Suite” from here on out, is an open-source initiative aimed at supporting developers in (end-to-end) testing their Magento 2 applications.
28 |
29 | By combining the power of Behavior Driven Development (BDD) with the flexibility of Playwright and the clarity of Gherkin syntax embedded in JSDoc comments, we aim to make testing more accessible, readable, and maintainable for both developers and non-technical stakeholders.
30 |
31 | Please note: this Testing Suite should only be run in a testing environment.
32 |
33 | ## Why BDD and Gherkin in JSDoc for Magento 2
34 | Testing in Magento 2 can be complex due to its extensive functionality and customizable nature. Traditional testing methods often result in tests that are hard to read and maintain, especially as the application grows.
35 |
36 | **Behavior Driven Development (BDD)** focuses on the behavior of the application from the user's perspective. It encourages collaboration between developers, QA engineers, and business stakeholders by using a shared language to describe application behavior.
37 |
38 | [Gherkin syntax](https://cucumber.io/docs/gherkin/reference/) is a domain-specific language that uses natural language constructs to describe software behaviors. By embedding Gherkin steps directly into JSDoc comments, we achieve several benefits:
39 |
40 | - **Readability**: tests become self-documenting and easier to understand.
41 | - **Maintainability**: changes in requirements can be quickly reflected in the test descriptions.
42 | - **Collaboration**: non-technical team members can read and even help write test cases.
43 | - **Integration**: embedding in JSDoc keeps the test descriptions close to the implementation, reducing context switching.
44 |
45 | [Playwright](https://playwright.dev/) is a powerful automation library that supports all modern browsers. It offers fast and reliable cross-browser testing, which is essential for ensuring Magento 2 applications work seamlessly across different environments.
46 |
47 | By integrating these technologies, this testing suite provides a robust framework that simplifies the process of writing, running, and maintaining end-to-end tests for Magento 2.
48 |
49 | ## Features
50 | - **Gherkin Syntax in JSDoc**: write human-readable test steps directly in your code comments.
51 | - **Playwright integration**: utilize Playwright's powerful automation capabilities for testing across different browsers.
52 | - **Magento 2 specific utilities**: predefined steps and helpers tailored for Magento 2's unique features.
53 | - **Collaborative testing**: enable collaboration between technical and non-technical team members.
54 | - **Extensible architecture**: easily extend and customize to fit your project's needs.
55 |
56 |
57 | ## Getting Started
58 | Please note that this Testing Suite is currently in alpha testing. If you are having problems setting up the Testing Suite for your website, feel free to open a ticket in Github.
59 |
60 | ### Prerequisites
61 | - **Node.js**: Ensure you have Node.js installed (version 14 or higher).
62 | - **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.
63 | - **Git**: Version control system to clone the repository.
64 |
65 |
66 |
67 | ### Installation
68 |
69 | 1. **Clone the repository**
70 |
71 | ```bash
72 | git clone https://github.com/elgentos/magento2-bdd-e2e-testing-suite.git
73 | ```
74 |
75 | 2. **Navigate to the project directory**
76 |
77 | ```bash
78 | cd magento2-bdd-e2e-testing-suite
79 | ```
80 |
81 | 3. **Required Dependencies**
82 |
83 | The project requires the following npm packages:
84 | - @playwright/test (^1.47.2) - Core Playwright testing framework
85 | - @faker-js/faker (^9.4.0) - For generating test data
86 | - @types/node (^22.7.4) - TypeScript definitions for Node.js
87 | - dotenv (^16.4.5) - For environment variable management
88 |
89 | 4. **Install all dependencies**
90 |
91 | ```bash
92 | npm install
93 | ```
94 |
95 | 5. **Install Playwright browsers**
96 |
97 | ```bash
98 | npx playwright install --with-deps
99 | ```
100 |
101 | 6. **Configure environment**
102 |
103 | Copy the example environment files and update them with your configuration:
104 |
105 | ```bash
106 | cp .env.example .env
107 | cp playwright.config.example.ts playwright.config.ts
108 | cp bypass-captcha.config.example.ts bypass-captcha.config.ts
109 | ```
110 |
111 | Update `.env` with your Magento 2 instance URL and other necessary settings.
112 |
113 | 7. **Update files in the `config` folder**
114 |
115 | Input variables, slugs, selectors and expected text are stored in files within the `config` folder. Update these to match your website's configuration.
116 |
117 | ### Before you run
118 | Before you run our Testing Suite, you will need to perform a few steps to set-up your environment. Note that we are working on an update to automate these steps. Check out the [Contributing](#contributing) section if you want to help!
119 |
120 | 1. Create an account (and set up environment)
121 |
122 | The testing suite contains a test to ensure account creation is possible. Once again, due to the nature of running tests, it’s necessary to create an account before the other tests can be run. You can choose to run `register.spec.ts` to create an account or do it by hand, then update your `.env` variable to ensure tests can use an existing account. You can also run the following command, which will run `register.spec.ts` as well as `setup.spec.ts`:
123 |
124 | ```bash
125 | npx playwright test --grep @setup
126 | ```
127 | 2. Create a coupon code in your Magento 2 environment and/or set an existing coupon code in the `.env` file.
128 |
129 | The Testing Suite offers multiple tests to ensure the proper functionality of coupon codes. To do this, you will need to either set an existing coupon code in your `.env` file, or create one and add it.
130 |
131 | 3. Note that the test “I can change my password” is set to `skip`.
132 |
133 | This is because updating your password in the middle of running tests will invalidate any subsequent test that requires a password. To test this functionality, change the line from `test.skip('I can change my password')` to `test.only('I can change my password')`. This will ensure *only* this test will be performed. Don’t forget to set it back to `test.skip()` after ensuring this functionality works. This issue is known and will be fixed in the future.
134 |
135 |
136 | ## How to use
137 | The Testing Suite offers a variety of tests for your Magento 2 application in Chromium, Firefox, and Webkit.
138 |
139 | ### Running tests
140 | To run ALL tests, run the following command.
141 | **Note that this will currently not work. Please add `–-grep-invert @setup` to the command below to skip certain tests.** You can learn more about this in the following section.
142 |
143 | ```bash
144 | npx playwright test
145 | ```
146 |
147 | This command will run all tests located in the `tests` directory.
148 |
149 | You can also run a specific test file:
150 |
151 | ```bash
152 | npx playwright test tests/example.test.js
153 | ```
154 |
155 | 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:
156 |
157 | ```bash
158 | npx playwright test --ui
159 | ```
160 |
161 | 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:
162 |
163 | ```bash
164 | npx playwright test --trace on
165 | ```
166 |
167 | ### Skipping specific tests
168 | 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`.
169 |
170 | ```bash
171 | npx playwright test –-grep-invert @setup
172 | ```
173 |
174 |
175 | ### Tags and Annotations
176 | 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:
177 |
178 | ```bash
179 | npx playwright test –-grep @coupon-code
180 | ```
181 |
182 | You can also run multiple tags with logic operators:
183 |
184 | ```bash
185 | npx playwright test –-grep ”@coupon-code|@cart”
186 | ```
187 |
188 |
189 | 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’.
190 |
191 |
192 | ```bash
193 | npx playwright test –-grep-invert @coupon-code
194 | ```
195 |
196 |
197 | ## Examples
198 |
199 | Below are some example tests to illustrate how to write and structure your tests.
200 |
201 | ### User registration test
202 |
203 | ```javascript
204 | /**
205 | * @feature User Registration
206 | * @scenario User successfully registers on the website
207 | * @given I am on the registration page
208 | * @when I fill in the registration form with valid data
209 | * @and I submit the form
210 | * @then I should see a confirmation message
211 | */
212 | test('User can register an account', async ({ page }) => {
213 | // Implementation details
214 | });
215 | ```
216 |
217 | ### Checkout process test
218 |
219 | ```javascript
220 | /**
221 | * @feature Product Checkout
222 | * @scenario User completes a purchase
223 | * @given I have a product in my cart
224 | * @when I proceed to checkout
225 | * @and I complete the checkout process
226 | * @then I should receive an order confirmation
227 | */
228 | test('User can complete the checkout process', async ({ page }) => {
229 | // Implementation details
230 | });
231 | ```
232 |
233 | ## Contributing
234 |
235 | We welcome contributions to enhance this project! Here's how you can get involved:
236 |
237 | 1. **Clone this repository**
238 |
239 | ```bash
240 | git clone https://github.com/elgentos/magento2-bdd-e2e-testing-suite
241 | ```
242 |
243 | 2. **Create a branch**
244 |
245 | ```bash
246 | git checkout -b feature/your-feature-name
247 | ```
248 |
249 | 3. **Make your changes**
250 |
251 | 4. **Commit your changes**
252 |
253 | ```bash
254 | git commit -m 'Add a new feature'
255 | ```
256 |
257 | 5. **Push to your fork**
258 |
259 | ```bash
260 | git push origin feature/your-feature-name
261 | ```
262 |
263 | 6. **Open a pull request**: Go to the original repository and open a pull request with a detailed description of your changes.
264 |
265 | ## License
266 |
267 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
268 |
269 | ## Contact
270 |
271 | If you have any questions, suggestions, or feedback, please open an issue on GitHub.
272 |
273 | ## Known issues
274 | Running Playwright/the Testing Suite on Ubuntu has known issues with the Webkit browser engine (especially in headless mode). Please see [31615](https://github.com/microsoft/playwright/issues/31615), [13060](https://github.com/microsoft/playwright/issues/13060), [4235](https://github.com/microsoft/playwright/issues/4236), [Stack Overflow article](https://stackoverflow.com/questions/71589815/in-playwright-cant-use-page-goto-with-headless-webkit) for more information.
275 |
276 | **A temporary (sort of) workaround**: if functions like `page.goto()` or `locator.click()` give issues, it can sometimes be fixed by deleting playwright, then reinstalling with dependencies (see below). Also note that you should not use a built-in terminal (like the one in VS Code), but rather run the tests using a separate terminal.
277 |
278 | ```bash
279 | npx playwright uninstall
280 | npx playwright install --with-deps
281 | ```
282 |
--------------------------------------------------------------------------------
/bypass-captcha.config.example.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file is used to set up the CAPTCHA bypass for your tests.
3 | * It will set the global cookie to bypass CAPTCHA for Magento 2.
4 | * See: https://github.com/elgentos/magento2-bypass-captcha-cookie
5 | *
6 | */
7 | import { FullConfig } from '@playwright/test';
8 | import * as playwright from 'playwright';
9 | import dotenv from 'dotenv';
10 |
11 | dotenv.config();
12 |
13 | async function globalSetup(config: FullConfig) {
14 | const bypassCaptcha = process.env.CAPTCHA_BYPASS === 'true';
15 |
16 | for (const project of config.projects) {
17 | const { storageState, browserName = 'chromium' } = project.use || {};
18 | if (storageState) {
19 | const browserType = playwright[browserName];
20 | const browser = await browserType.launch();
21 | const context = await browser.newContext();
22 |
23 | if (bypassCaptcha) {
24 | // Set the global cookie to bypass CAPTCHA
25 | await context.addCookies([{
26 | name: 'disable_captcha', // this cookie will be read by 'magento2-bypass-captcha-cookie' module.
27 | value: '', // Fill with generated token.
28 | domain: 'hyva-demo.elgentos.io', // Replace with your domain
29 | path: '/',
30 | httpOnly: true,
31 | secure: true,
32 | sameSite: 'Lax',
33 | }]);
34 | console.log(`CAPTCHA bypass enabled for browser: ${project.name}`);
35 | } else {
36 | // Do nothing.
37 | }
38 |
39 | await context.storageState({ path: `./auth-storage/${project.name}-storage-state.json` });
40 | await browser.close();
41 | }
42 | }
43 | }
44 |
45 | export default globalSetup;
46 |
--------------------------------------------------------------------------------
/install.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const fs = require('fs');
3 | const path = require('path');
4 | const readline = require('readline');
5 | const { execSync } = require('child_process');
6 |
7 | const rl = readline.createInterface({
8 | input: process.stdin,
9 | output: process.stdout
10 | });
11 |
12 | const question = (query) => new Promise((resolve) => rl.question(query, resolve));
13 |
14 | async function main() {
15 | try {
16 | // Get Magento 2 project path
17 | const projectPath = await question('Please enter the path to your Magento 2 project (relative or absolute): ');
18 | const magentoPath = path.resolve(projectPath);
19 |
20 | if (!fs.existsSync(magentoPath)) {
21 | console.error(`Directory ${magentoPath} does not exist!`);
22 | process.exit(1);
23 | }
24 |
25 | // Handle package.json separately
26 | const sourcePackageJsonPath = path.resolve(__dirname, 'package.json');
27 | const targetPackageJsonPath = path.join(magentoPath, 'package.json');
28 |
29 | let finalPackageJson;
30 | const sourcePackageJson = JSON.parse(fs.readFileSync(sourcePackageJsonPath, 'utf8'));
31 |
32 | if (fs.existsSync(targetPackageJsonPath)) {
33 | console.log('Existing package.json found, merging dependencies...');
34 | const targetPackageJson = JSON.parse(fs.readFileSync(targetPackageJsonPath, 'utf8'));
35 |
36 | // Merge dependencies and devDependencies
37 | finalPackageJson = {
38 | ...targetPackageJson,
39 | dependencies: {
40 | ...(targetPackageJson.dependencies || {}),
41 | ...(sourcePackageJson.dependencies || {})
42 | },
43 | devDependencies: {
44 | ...(targetPackageJson.devDependencies || {}),
45 | ...(sourcePackageJson.devDependencies || {})
46 | }
47 | };
48 |
49 | // Write merged package.json
50 | fs.writeFileSync(targetPackageJsonPath, JSON.stringify(finalPackageJson, null, 2));
51 | console.log('Successfully merged package.json files');
52 | } else {
53 | // If no existing package.json, just copy the source one
54 | fs.copyFileSync(sourcePackageJsonPath, targetPackageJsonPath);
55 | }
56 |
57 | // Copy remaining files
58 | const filesToCopy = [
59 | { src: 'tests', dest: 'tests' },
60 | { src: '.env.example', dest: '.env' },
61 | { src: 'bypass-captcha.config.example.ts', dest: 'bypass-captcha.config.ts' },
62 | { src: 'playwright.config.example.ts', dest: 'playwright.config.ts' }
63 | ];
64 |
65 | for (const file of filesToCopy) {
66 | const srcPath = path.resolve(__dirname, file.src);
67 | const destPath = path.join(magentoPath, file.dest);
68 |
69 | if (file.src === 'tests') {
70 | if (fs.existsSync(srcPath)) {
71 | execSync(`cp -R "${srcPath}" "${destPath}"`);
72 | }
73 | } else {
74 | if (fs.existsSync(srcPath)) {
75 | fs.copyFileSync(srcPath, destPath);
76 | }
77 | }
78 | }
79 |
80 | // Update playwright config
81 | const playwrightConfigPath = path.join(magentoPath, 'playwright.config.ts');
82 | if (fs.existsSync(playwrightConfigPath)) {
83 | let playwrightConfig = fs.readFileSync(playwrightConfigPath, 'utf8');
84 | playwrightConfig = playwrightConfig.replace(
85 | 'bypass-captcha.config.example.ts',
86 | 'bypass-captcha.config.ts'
87 | );
88 | fs.writeFileSync(playwrightConfigPath, playwrightConfig);
89 | }
90 |
91 | // Get current user
92 | const currentUser = execSync('whoami').toString().trim();
93 |
94 | // Environment variables with defaults
95 | const envVars = {
96 | 'MAGENTO_ADMIN_SLUG': { default: 'admin' },
97 | 'MAGENTO_ADMIN_USERNAME': { default: currentUser },
98 | 'MAGENTO_ADMIN_PASSWORD': { default: currentUser + '123' },
99 | 'MAGENTO_NEW_ACCOUNT_PASSWORD': { default: 'Test1234!' },
100 | 'MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM': { default: 'user-CHROMIUM@elgentos.nl' },
101 | 'MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX': { default: 'user-FIREFOX@elgentos.nl' },
102 | 'MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT': { default: 'user-WEBKIT@elgentos.nl' },
103 | 'MAGENTO_EXISTING_ACCOUNT_PASSWORD': { default: 'Test1234!' },
104 | 'MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD': { default: 'AanpassenKan@0212' },
105 | 'MAGENTO_COUPON_CODE_CHROMIUM': { default: 'CHROMIUM321' },
106 | 'MAGENTO_COUPON_CODE_FIREFOX': { default: 'FIREFOX321' },
107 | 'MAGENTO_COUPON_CODE_WEBKIT': { default: 'WEBKIT321' }
108 | };
109 |
110 | // Read and update .env file
111 | const envPath = path.join(magentoPath, '.env');
112 | let envContent = '';
113 |
114 | for (const [key, value] of Object.entries(envVars)) {
115 | const userInput = await question(`Enter ${key} (default: ${value.default}): `);
116 | envContent += `${key}=${userInput || value.default}\n`;
117 | }
118 |
119 | fs.writeFileSync(envPath, envContent);
120 |
121 | console.log('\nInstallation completed successfully!');
122 | console.log('\nFor more information, please visit:');
123 | console.log('https://wiki.elgentos.nl/doc/stappenplan-testing-suite-implementeren-voor-klanten-hCGe4hVQvN');
124 |
125 | rl.close();
126 | } catch (error) {
127 | console.error('An error occurred:', error);
128 | rl.close();
129 | process.exit(1);
130 | }
131 | }
132 |
133 | main();
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "magento2-bdd-e2e-testing-suite",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {},
6 | "keywords": [],
7 | "author": "",
8 | "license": "ISC",
9 | "description": "",
10 | "devDependencies": {
11 | "@faker-js/faker": "^9.4.0",
12 | "@playwright/test": "^1.47.2",
13 | "@types/node": "^22.7.4"
14 | },
15 | "dependencies": {
16 | "dotenv": "^16.4.5",
17 | "csv-parse": "^5.5.3"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/playwright.config.example.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 | import dotenv from 'dotenv';
3 | import path from 'path';
4 | import fs from "node:fs";
5 | dotenv.config({ path: path.resolve(__dirname, '.env') });
6 |
7 | function getTestFiles(baseDir: string, customDir: string): string[] {
8 | const baseFiles = new Set(fs.readdirSync(baseDir).filter(file => file.endsWith('.spec.ts')));
9 | const customFiles = fs.readdirSync(customDir).filter(file => file.endsWith('.spec.ts'));
10 |
11 | if(customFiles.length === 0) {
12 | return Array.from(baseFiles);
13 | }
14 |
15 | const testFiles = new Set();
16 |
17 | // Get base files that have an override in custom
18 | for (const file of baseFiles) {
19 | const baseFilePath = path.join(baseDir, file);
20 | const customFilePath = path.join(customDir, file);
21 |
22 | testFiles.add(fs.existsSync(customFilePath) ? customFilePath : baseFilePath);
23 | }
24 |
25 | // Add custom tests that aren't in base
26 | for (const file of customFiles) {
27 | if (!baseFiles.has(file)) {
28 | testFiles.add(path.join(customDir, file));
29 | }
30 | }
31 |
32 | return Array.from(testFiles);
33 | }
34 |
35 | const testFiles = getTestFiles(
36 | path.join(__dirname, 'tests', 'base'),
37 | path.join(__dirname, 'tests', 'custom'),
38 | );
39 |
40 | /**
41 | * See https://playwright.dev/docs/test-configuration.
42 | */
43 | export default defineConfig({
44 | testDir: './tests',
45 | /* Run tests in files in parallel */
46 | fullyParallel: true,
47 | /* Fail the build on CI if you accidentally left test.only in the source code. */
48 | forbidOnly: !!process.env.CI,
49 | /* Retry on CI only */
50 | retries: process.env.CI ? 2 : 0,
51 | /* Opt out of parallel tests on CI. */
52 | workers: process.env.CI ? 1 : undefined,
53 | /* Increase default timeout */
54 | timeout: 120_000,
55 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */
56 | reporter: 'html',
57 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
58 | use: {
59 | baseURL: process.env.PLAYWRIGHT_BASE_URL || 'https://hyva-demo.elgentos.io/',
60 |
61 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
62 | trace: 'on-first-retry',
63 |
64 | /* Ignore https errors if they apply (should only happen on local) */
65 | ignoreHTTPSErrors: true,
66 | },
67 | /* Setup for global cookie to bypass CAPTCHA, remove '.example' when used */
68 | globalSetup: require.resolve('./bypass-captcha.config.example.ts'),
69 |
70 | /* Configure projects for major browsers */
71 | projects: [
72 | // Import our auth.setup.ts file
73 | //{ name: 'setup', testMatch: /.*\.setup\.ts/ },
74 |
75 | {
76 | name: 'chromium',
77 | testMatch: testFiles,
78 | use: {
79 | ...devices['Desktop Chrome'],
80 | storageState: './auth-storage/chromium-storage-state.json',
81 | },
82 | },
83 |
84 | {
85 | name: 'firefox',
86 | testMatch: testFiles,
87 | use: {
88 | ...devices['Desktop Firefox'],
89 | storageState: './auth-storage/firefox-storage-state.json', },
90 | },
91 |
92 | {
93 | name: 'webkit',
94 | testMatch: testFiles,
95 | use: {
96 | ...devices['Desktop Safari'],
97 | storageState: './auth-storage/webkit-storage-state.json',
98 | },
99 | },
100 |
101 | /* Test against mobile viewports. */
102 | // {
103 | // name: 'Mobile Chrome',
104 | // use: { ...devices['Pixel 5'] },
105 | // },
106 | // {
107 | // name: 'Mobile Safari',
108 | // use: { ...devices['iPhone 12'] },
109 | // },
110 |
111 | /* Test against branded browsers. */
112 | // {
113 | // name: 'Microsoft Edge',
114 | // use: { ...devices['Desktop Edge'], channel: 'msedge' },
115 | // },
116 | // {
117 | // name: 'Google Chrome',
118 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
119 | // },
120 | ],
121 |
122 | /* Run your local dev server before starting the tests */
123 | // webServer: {
124 | // command: 'npm run start',
125 | // url: 'http://127.0.0.1:3000',
126 | // reuseExistingServer: !process.env.CI,
127 | // },
128 | });
129 |
--------------------------------------------------------------------------------
/tests/auth.setup.ts:
--------------------------------------------------------------------------------
1 | import { test as setup, expect } from '@playwright/test';
2 | import path from 'path';
3 | import slugs from './base/config/slugs.json';
4 | import selectors from './base/config/selectors/selectors.json';
5 |
6 | const authFile = path.join(__dirname, '../playwright/.auth/user.json');
7 |
8 | setup('authenticate', async ({ page, browserName }) => {
9 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
10 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
11 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
12 |
13 | if(!emailInputValue || !passwordInputValue) {
14 | 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.");
15 | }
16 |
17 | // Perform authentication steps. Replace these actions with your own.
18 | await page.goto(slugs.account.loginSlug);
19 | await page.getByLabel(selectors.credentials.emailFieldLabel, {exact: true}).fill(emailInputValue);
20 | await page.getByLabel(selectors.credentials.passwordFieldLabel, {exact: true}).fill(passwordInputValue);
21 | await page.getByRole('button', { name: selectors.credentials.loginButtonLabel }).click();
22 | // Wait until the page receives the cookies.
23 | //
24 | // Sometimes login flow sets cookies in the process of several redirects.
25 | // Wait for the final URL to ensure that the cookies are actually set.
26 | // await page.waitForURL('');
27 | // Alternatively, you can wait until the page reaches a state where all cookies are set.
28 | await expect(page.getByRole('link', { name: selectors.mainMenu.myAccountLogoutItem })).toBeVisible();
29 |
30 | // End of authentication steps.
31 |
32 | await page.context().storageState({ path: authFile });
33 | });
34 |
--------------------------------------------------------------------------------
/tests/base/account.spec.ts:
--------------------------------------------------------------------------------
1 | import {test, expect} from '@playwright/test';
2 | import {MainMenuPage} from './fixtures/mainmenu.page';
3 | import {LoginPage} from './fixtures/login.page';
4 | import {RegisterPage} from './fixtures/register.page';
5 | import {AccountPage} from './fixtures/account.page';
6 | import {NewsletterSubscriptionPage} from './fixtures/newsletter.page';
7 | import {faker} from '@faker-js/faker';
8 |
9 | import slugs from './config/slugs.json';
10 | import UIReference from './config/element-identifiers/element-identifiers.json';
11 | import outcomeMarker from './config/outcome-markers/outcome-markers.json';
12 |
13 | // Before each test, log in
14 | test.beforeEach(async ({ page, browserName }) => {
15 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
16 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
17 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
18 |
19 | if(!emailInputValue || !passwordInputValue) {
20 | 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.");
21 | }
22 |
23 | const loginPage = new LoginPage(page);
24 | await loginPage.login(emailInputValue, passwordInputValue);
25 | });
26 |
27 | test.describe('Account information actions', {annotation: {type: 'Account Dashboard', description: 'Test for Account Information'},}, () => {
28 |
29 | test.beforeEach(async ({page}) => {
30 | await page.goto(slugs.account.accountOverviewSlug);
31 | await page.waitForLoadState();
32 | });
33 |
34 | /**
35 | * @feature Magento 2 Change Password
36 | * @scenario User changes their password
37 | * @given I am logged in
38 | * @and I am on the Account Dashboard page
39 | * @when I navigate to the Account Information page
40 | * @and I check the 'change password' option
41 | * @when I fill in the new credentials
42 | * @and I click Save
43 | * @then I should see a notification that my password has been updated
44 | * @and I should be able to login with my new credentials.
45 | */
46 | test('I can change my password',{ tag: ['@account-credentials', '@hot'] }, async ({page, browserName}, testInfo) => {
47 |
48 | // Create instances and set variables
49 | const mainMenu = new MainMenuPage(page);
50 | const registerPage = new RegisterPage(page);
51 | const accountPage = new AccountPage(page);
52 | const loginPage = new LoginPage(page);
53 |
54 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
55 | let randomNumberforEmail = Math.floor(Math.random() * 101);
56 | let emailPasswordUpdatevalue = `passwordupdate-${randomNumberforEmail}-${browserEngine}@example.com`;
57 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
58 | let changedPasswordValue = process.env.MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD;
59 |
60 | // Log out of current account
61 | if(await page.getByRole('link', { name: UIReference.mainMenu.myAccountLogoutItem }).isVisible()){
62 | await mainMenu.logout();
63 | }
64 |
65 | // Create account
66 | if(!changedPasswordValue || !passwordInputValue) {
67 | throw new Error("Changed password or original password in your .env file is not defined or could not be read.");
68 | }
69 |
70 | await registerPage.createNewAccount(faker.person.firstName(), faker.person.lastName(), emailPasswordUpdatevalue, passwordInputValue);
71 |
72 | // Update password
73 | await page.goto(slugs.account.changePasswordSlug);
74 | await page.waitForLoadState();
75 | await accountPage.updatePassword(passwordInputValue, changedPasswordValue);
76 |
77 | // If login with changePasswordValue is possible, then password change was succesful.
78 | await loginPage.login(emailPasswordUpdatevalue, changedPasswordValue);
79 |
80 | // Logout again, login with original account
81 | await mainMenu.logout();
82 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
83 | if(!emailInputValue) {
84 | 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.");
85 | }
86 | await loginPage.login(emailInputValue, passwordInputValue);
87 | });
88 | });
89 |
90 | test.describe.serial('Account address book actions', { annotation: {type: 'Account Dashboard', description: 'Tests for the Address Book'},}, () => {
91 | test.beforeEach(async ({page}) => {
92 | await page.goto(slugs.account.addressBookSlug);
93 | await page.waitForLoadState();
94 | });
95 |
96 | /**
97 | * @feature Magento 2 Add First Address to Account
98 | * @scenario User adds a first address to their account
99 | * @given I am logged in
100 | * @and I am on the account dashboard page
101 | * @when I go to the page where I can add my address
102 | * @and I haven't added an address yet
103 | * @when I fill in the required information
104 | * @and I click the save button
105 | * @then I should see a notification my address has been updated.
106 | * @and The new address should be selected as default and shipping address
107 | */
108 |
109 | test('I can add my first address',{ tag: ['@account-credentials', '@hot'] }, async ({page}, testInfo) => {
110 | const accountPage = new AccountPage(page);
111 | let addNewAddressTitle = page.getByRole('heading', {level: 1, name: UIReference.newAddress.addNewAddressTitle});
112 |
113 | if(await addNewAddressTitle.isHidden()) {
114 | await accountPage.deleteAllAddresses();
115 | testInfo.annotations.push({ type: 'Notification: deleted addresses', description: `All addresses are deleted to recreate the first address flow.` });
116 | await page.goto(slugs.account.addressNewSlug);
117 | }
118 |
119 | await accountPage.addNewAddress();
120 | });
121 |
122 | /**
123 | * @given I am logged in
124 | * @and I am on the account dashboard page
125 | * @when I go to the page where I can add another address
126 | * @when I fill in the required information
127 | * @and I click the save button
128 | * @then I should see a notification my address has been updated.
129 | * @and The new address should be listed
130 | */
131 | test('I can add another address',{ tag: ['@account-credentials', '@hot'] }, async ({page}) => {
132 | await page.goto(slugs.account.addressNewSlug);
133 | const accountPage = new AccountPage(page);
134 |
135 | await accountPage.addNewAddress();
136 | });
137 |
138 | /**
139 | * @feature Magento 2 Update Address in Account
140 | * @scenario User updates an existing address to their account
141 | * @given I am logged in
142 | * @and I am on the account dashboard page
143 | * @when I go to the page where I can see my address(es)
144 | * @when I click on the button to edit the address
145 | * @and I fill in the required information correctly
146 | * @then I click the save button
147 | * @then I should see a notification my address has been updated.
148 | * @and The updated address should be visible in the addres book page.
149 | */
150 | test('I can edit an existing address',{ tag: ['@account-credentials', '@hot'] }, async ({page}) => {
151 | const accountPage = new AccountPage(page);
152 | await page.goto(slugs.account.addressNewSlug);
153 | let editAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.editAddressIconButton}).first();
154 |
155 | if(await editAddressButton.isHidden()){
156 | // The edit address button was not found, add another address first.
157 | await accountPage.addNewAddress();
158 | }
159 |
160 | await page.goto(slugs.account.addressBookSlug);
161 | await accountPage.editExistingAddress();
162 | });
163 |
164 | /**
165 | * @feature Magento 2 Delete Address from account
166 | * @scenario User removes an address from their account
167 | * @given I am logged in
168 | * @and I am on the account dashboard page
169 | * @when I go to the page where I can see my address(es)
170 | * @when I click the trash button for the address I want to delete
171 | * @and I click the confirmation button
172 | * @then I should see a notification my address has been deleted.
173 | * @and The address should be removed from the overview.
174 | */
175 | test('I can delete an address',{ tag: ['@account-credentials', '@hot'] }, async ({page}, testInfo) => {
176 | const accountPage = new AccountPage(page);
177 |
178 | let deleteAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.addressDeleteIconButton}).first();
179 |
180 | if(await deleteAddressButton.isHidden()) {
181 | await page.goto(slugs.account.addressNewSlug);
182 | await accountPage.addNewAddress();
183 | }
184 | await accountPage.deleteFirstAddressFromAddressBook();
185 | });
186 | });
187 |
188 | test.describe('Newsletter actions', { annotation: {type: 'Account Dashboard', description: 'Newsletter tests'},}, () => {
189 | test.beforeEach(async ({page}) => {
190 | await page.goto(slugs.account.accountOverviewSlug);
191 | });
192 |
193 | /**
194 | * @feature Magento 2 newsletter subscriptions
195 | * @scenario User (un)subscribes from a newsletter
196 | * @given I am logged in
197 | * @and I am on the account dashboard page
198 | * @when I click on the newsletter link in the sidebar
199 | * @then I should navigate to the newsletter subscription page
200 | * @when I (un)check the subscription button
201 | * @then I should see a message confirming my action
202 | * @and My subscription option should be updated.
203 | */
204 | test('I can update my newsletter subscription',{ tag: ['@newsletter-actions', '@cold'] }, async ({page, browserName}) => {
205 | test.skip(browserName === 'webkit', '.click() does not work, still searching for a workaround');
206 | const newsletterPage = new NewsletterSubscriptionPage(page);
207 | let newsletterLink = page.getByRole('link', { name: UIReference.accountDashboard.links.newsletterLink });
208 | const newsletterCheckElement = page.getByLabel(UIReference.newsletterSubscriptions.generalSubscriptionCheckLabel);
209 |
210 | await newsletterLink.click();
211 | await expect(page.getByText(outcomeMarker.account.newsletterSubscriptionTitle, { exact: true })).toBeVisible();
212 |
213 | let updateSubscription = await newsletterPage.updateNewsletterSubscription();
214 |
215 | await newsletterLink.click();
216 |
217 | if(updateSubscription){
218 | await expect(newsletterCheckElement).toBeChecked();
219 | } else {
220 | await expect(newsletterCheckElement).not.toBeChecked();
221 | }
222 | });
223 | });
224 |
--------------------------------------------------------------------------------
/tests/base/cart.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 | import { ProductPage } from './fixtures/product.page';
3 | import { MainMenuPage } from './fixtures/mainmenu.page';
4 | import {LoginPage} from './fixtures/login.page';
5 | import { CartPage } from './fixtures/cart.page';
6 |
7 | import slugs from './config/slugs.json';
8 | import UIReference from './config/element-identifiers/element-identifiers.json';
9 |
10 | test.describe('Cart functionalities (guest)', () => {
11 | /**
12 | * @feature BeforeEach runs before each test in this group.
13 | * @scenario Add a product to the cart and confirm it's there.
14 | * @given I am on any page
15 | * @when I navigate to a (simple) product page
16 | * @and I add it to my cart
17 | * @then I should see a notification
18 | * @when I click the cart in the main menu
19 | * @then the minicart should become visible
20 | * @and I should see the product in the minicart
21 | */
22 | test.beforeEach(async ({ page }) => {
23 | const productPage = new ProductPage(page);
24 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug);
25 | // await mainMenu.openMiniCart();
26 | // await expect(page.getByText(outcomeMarker.miniCart.simpleProductInCartTitle)).toBeVisible();
27 | await page.goto(slugs.cart.cartSlug);
28 | });
29 |
30 | /**
31 | * @feature Product can be added to cart
32 | * @scenario User adds a product to their cart
33 | * @given I have added a product to my cart
34 | * @and I am on the cart page
35 | * @then I should see the name of the product in my cart
36 | */
37 | test('Product can be added to cart',{ tag: ['@cart', '@cold'],}, async ({page}) => {
38 | await expect(page.getByRole('strong').getByRole('link', {name: UIReference.productPage.simpleProductTitle}), `Product is visible in cart`).toBeVisible();
39 | });
40 |
41 | /**
42 | * @feature Product permanence after login
43 | * @scenario A product added to the cart should still be there after user has logged in
44 | * @given I have a product in my cart
45 | * @when I log in
46 | * @then I should still have that product in my cart
47 | */
48 | test('Product should remain in cart after logging in',{ tag: ['@cart', '@account', '@hot']}, async ({page, browserName}) => {
49 | await test.step('Add another product to cart', async () =>{
50 | const productpage = new ProductPage(page);
51 | await page.goto(slugs.productpage.secondSimpleProductSlug);
52 | await productpage.addSimpleProductToCart(UIReference.productPage.secondSimpleProducTitle, slugs.productpage.secondSimpleProductSlug);
53 | });
54 |
55 | await test.step('Log in with account', async () =>{
56 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
57 | const loginPage = new LoginPage(page);
58 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
59 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
60 |
61 | if(!emailInputValue || !passwordInputValue) {
62 | 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.");
63 | }
64 |
65 | await loginPage.login(emailInputValue, passwordInputValue);
66 | });
67 |
68 | await page.goto(slugs.cart.cartSlug);
69 | await expect(page.getByRole('strong').getByRole('link', { name: UIReference.productPage.simpleProductTitle }),`${UIReference.productPage.simpleProductTitle} should still be in cart`).toBeVisible();
70 | await expect(page.getByRole('strong').getByRole('link', { name: UIReference.productPage.secondSimpleProducTitle }),`${UIReference.productPage.secondSimpleProducTitle} should still be in cart`).toBeVisible();
71 | });
72 |
73 | /**
74 | * @feature Remove product from cart
75 | * @scenario User has added a product and wants to remove it from the cart page
76 | * @given I have added a product to my cart
77 | * @and I am on the cart page
78 | * @when I click the delete button
79 | * @then I should see a notification that the product has been removed from my cart
80 | * @and I should no longer see the product in my cart
81 | */
82 | test('Remove product from cart',{ tag: ['@cart','@cold'],}, async ({page}) => {
83 | const cart = new CartPage(page);
84 | await cart.removeProduct(UIReference.productPage.simpleProductTitle);
85 | });
86 |
87 | /**
88 | * @feature Change quantity of products in cart
89 | * @scenario User has added a product and changes the quantity
90 | * @given I have a product in my cart
91 | * @and I am on the cart page
92 | * @when I change the quantity of the product
93 | * @and I click the update button
94 | * @then the quantity field should have the new amount
95 | * @and the subtotal/grand total should update
96 | */
97 | test('Change quantity of products in cart',{ tag: ['@cart', '@cold'],}, async ({page}) => {
98 | const cart = new CartPage(page);
99 | await cart.changeProductQuantity('2');
100 | });
101 |
102 | /**
103 | * @feature Discount Code
104 | * @scenario User adds a discount code to their cart
105 | * @given I have a product in my cart
106 | * @and I am on my cart page
107 | * @when I click on the 'add discount code' button
108 | * @then I fill in a code
109 | * @and I click on 'apply code'
110 | * @then I should see a confirmation that my code has been added
111 | * @and the code should be visible in the cart
112 | * @and a discount should be applied to the product
113 | */
114 | test('Add coupon code in cart',{ tag: ['@cart', '@coupon-code', '@cold']}, async ({page, browserName}) => {
115 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
116 | const cart = new CartPage(page);
117 | let discountCode = process.env[`MAGENTO_COUPON_CODE_${browserEngine}`];
118 |
119 | if(!discountCode) {
120 | throw new Error(`MAGENTO_COUPON_CODE_${browserEngine} appears to not be set in .env file. Value reported: ${discountCode}`);
121 | }
122 |
123 | await cart.applyDiscountCode(discountCode);
124 | });
125 |
126 | /**
127 | * @feature Remove discount code from cart
128 | * @scenario User has added a discount code, then removes it
129 | * @given I have a product in my cart
130 | * @and I am on my cart page
131 | * @when I add a discount code
132 | * @then I should see a notification
133 | * @and the code should be visible in the cart
134 | * @and a discount should be applied to a product
135 | * @when I click the 'cancel coupon' button
136 | * @then I should see a notification the discount has been removed
137 | * @and the discount should no longer be visible.
138 | */
139 | test('Remove coupon code from cart',{ tag: ['@cart', '@coupon-code', '@cold'] }, async ({page, browserName}) => {
140 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
141 | const cart = new CartPage(page);
142 | let discountCode = process.env[`MAGENTO_COUPON_CODE_${browserEngine}`];
143 |
144 | if(!discountCode) {
145 | throw new Error(`MAGENTO_COUPON_CODE_${browserEngine} appears to not be set in .env file. Value reported: ${discountCode}`);
146 | }
147 |
148 | await cart.applyDiscountCode(discountCode);
149 | await cart.removeDiscountCode();
150 | });
151 |
152 | /**
153 | * @feature Incorrect discount code check
154 | * @scenario The user provides an incorrect discount code, the system should reflect that
155 | * @given I have a product in my cart
156 | * @and I am on the cart page
157 | * @when I enter a wrong discount code
158 | * @then I should get a notification that the code did not work.
159 | */
160 |
161 | test('Using an invalid coupon code should give an error',{ tag: ['@cart', '@coupon-code', '@cold'] }, async ({page}) => {
162 | const cart = new CartPage(page);
163 | await cart.enterWrongCouponCode("Incorrect Coupon Code");
164 | });
165 | })
166 |
167 | test.describe('Price checking tests', () => {
168 |
169 | // Test: Configurable Product Input check from PDP to checkout
170 | // test.step: add configurable product to cart, return priceOnPDP and productAmount as variables
171 | // test.step: call function retrieveCheckoutPrices() to go to checkout, retrieve values
172 | // test.step: call function compareRetrievedPrices() to compare price on PDP to price in checkout
173 |
174 | /**
175 | * @feature Simple Product price/amount check from PDP to Checkout
176 | * @given none
177 | * @when I go to a (simple) product page
178 | * @and I add one or more to my cart
179 | * @when I go to the checkout
180 | * @then the amount of the product should be the same
181 | * @and the price in the checkout should equal the price of the product * the amount of the product
182 | */
183 | test('Simple product input to cart is consistent from PDP to checkout',{ tag: ['@cart-price-check', '@cold']}, async ({page}) => {
184 | var productPagePrice: string;
185 | var productPageAmount: string;
186 | var checkoutProductDetails: string[];
187 |
188 | const cart = new CartPage(page);
189 |
190 | await test.step('Step: Add simple product to cart', async () =>{
191 | const productPage = new ProductPage(page);
192 | await page.goto(slugs.productpage.simpleProductSlug);
193 | // set quantity to 2 so we can see that the math works
194 | await page.getByLabel(UIReference.productPage.quantityFieldLabel).fill('2');
195 |
196 | productPagePrice = await page.locator(UIReference.productPage.simpleProductPrice).innerText();
197 | productPageAmount = await page.getByLabel(UIReference.productPage.quantityFieldLabel).inputValue();
198 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug, '2');
199 |
200 | });
201 |
202 | await test.step('Step: go to checkout, get values', async () =>{
203 | await page.goto(slugs.checkout.checkoutSlug);
204 | await page.waitForLoadState();
205 |
206 | // returns productPriceInCheckout and productQuantityInCheckout
207 | checkoutProductDetails = await cart.getCheckoutValues(UIReference.productPage.simpleProductTitle, productPagePrice, productPageAmount);
208 | });
209 |
210 | await test.step('Step: Calculate and check expectations', async () =>{
211 | await cart.calculateProductPricesAndCompare(productPagePrice, productPageAmount, checkoutProductDetails[0], checkoutProductDetails[1]);
212 | });
213 |
214 | });
215 |
216 | /**
217 | * @feature Configurable Product price/amount check from PDP to Checkout
218 | * @given none
219 | * @when I go to a (configurable) product page
220 | * @and I add one or more to my cart
221 | * @when I go to the checkout
222 | * @then the amount of the product should be the same
223 | * @and the price in the checkout should equal the price of the product * the amount of the product
224 | */
225 | test('Configurable product input to cart is consistent from PDP to checkout',{ tag: ['@cart-price-check', '@cold']}, async ({page}) => {
226 | var productPagePrice: string;
227 | var productPageAmount: string;
228 | var checkoutProductDetails: string[];
229 |
230 | const cart = new CartPage(page);
231 |
232 | await test.step('Step: Add configurable product to cart', async () =>{
233 | const productPage = new ProductPage(page);
234 | // Navigate to the configurable product page so we can retrieve price and amount before adding it to cart
235 | await page.goto(slugs.productpage.configurableProductSlug);
236 | // set quantity to 2 so we can see that the math works
237 | await page.getByLabel('Quantity').fill('2');
238 |
239 | productPagePrice = await page.locator(UIReference.productPage.simpleProductPrice).innerText();
240 | productPageAmount = await page.getByLabel(UIReference.productPage.quantityFieldLabel).inputValue();
241 | await productPage.addConfigurableProductToCart(UIReference.productPage.configurableProductTitle, slugs.productpage.configurableProductSlug, '2');
242 |
243 | });
244 |
245 | await test.step('Step: go to checkout, get values', async () =>{
246 | await page.goto(slugs.checkout.checkoutSlug);
247 | await page.waitForLoadState();
248 |
249 | // returns productPriceInCheckout and productQuantityInCheckout
250 | checkoutProductDetails = await cart.getCheckoutValues(UIReference.productPage.configurableProductTitle, productPagePrice, productPageAmount);
251 | });
252 |
253 | await test.step('Step: Calculate and check expectations', async () =>{
254 | await cart.calculateProductPricesAndCompare(productPagePrice, productPageAmount, checkoutProductDetails[0], checkoutProductDetails[1]);
255 | });
256 |
257 | });
258 | });
259 |
--------------------------------------------------------------------------------
/tests/base/category.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 |
3 | import CategoryPage from './fixtures/category.page';
4 |
5 | import slugs from './config/slugs.json';
6 | import UIReference from './config/element-identifiers/element-identifiers.json';
7 |
8 | test('Filter_category_on_size',{ tag: ['@category', '@cold']}, async ({page, browserName}) => {
9 | const categoryPage = new CategoryPage(page);
10 | await categoryPage.goToCategoryPage();
11 |
12 | await categoryPage.filterOnSize(browserName);
13 | });
14 |
15 | test('Sort_category_by_price',{ tag: ['@category', '@cold']}, async ({page}) => {
16 | const categoryPage = new CategoryPage(page);
17 | await categoryPage.goToCategoryPage();
18 |
19 | await categoryPage.sortProducts('price');
20 | });
21 |
22 | test('Change_amount_of_products_shown',{ tag: ['@category', '@cold'],}, async ({page}) => {
23 | const categoryPage = new CategoryPage(page);
24 | await categoryPage.goToCategoryPage();
25 |
26 | await categoryPage.showMoreProducts();
27 | // insert your code here
28 | });
29 |
30 | test('Switch_from_grid_to_list_view',{ tag: ['@category', '@cold'],}, async ({page}) => {
31 | const categoryPage = new CategoryPage(page);
32 | await categoryPage.goToCategoryPage();
33 | await categoryPage.switchView();
34 | });
--------------------------------------------------------------------------------
/tests/base/checkout.spec.ts:
--------------------------------------------------------------------------------
1 | import {test, expect} from '@playwright/test';
2 | import {LoginPage} from './fixtures/login.page';
3 | import {ProductPage} from './fixtures/product.page';
4 | import {AccountPage} from './fixtures/account.page';
5 | import { CheckoutPage } from './fixtures/checkout.page';
6 |
7 | import slugs from './config/slugs.json';
8 | import UIReference from './config/element-identifiers/element-identifiers.json';
9 |
10 |
11 | /**
12 | * @feature BeforeEach runs before each test in this group.
13 | * @scenario Add product to the cart, confirm it's there, then move to checkout.
14 | * @given I am on any page
15 | * @when I navigate to a (simple) product page
16 | * @and I add it to my cart
17 | * @then I should see a notification
18 | * @when I navigate to the checkout
19 | * @then the checkout page should be shown
20 | * @and I should see the product in the minicart
21 | */
22 | test.beforeEach(async ({ page }) => {
23 | const productPage = new ProductPage(page);
24 |
25 | await page.goto(slugs.productpage.simpleProductSlug);
26 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug);
27 | await page.goto(slugs.checkout.checkoutSlug);
28 | });
29 |
30 |
31 | test.describe('Checkout (login required)', () => {
32 | // Before each test, log in
33 | test.beforeEach(async ({ page, browserName }) => {
34 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
35 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
36 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
37 |
38 | if(!emailInputValue || !passwordInputValue) {
39 | 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.");
40 | }
41 |
42 | const loginPage = new LoginPage(page);
43 | await loginPage.login(emailInputValue, passwordInputValue);
44 | await page.goto(slugs.checkout.checkoutSlug);
45 | });
46 |
47 | /**
48 | * @feature Automatically fill in certain data in checkout (if user is logged in)
49 | * @scenario When the user navigates to the checkout (with a product), their name and address should be filled in.
50 | * @given I am logged in
51 | * @and I have a product in my cart
52 | * @and I have navigated to the checkout page
53 | * @then My name and address should already be filled in
54 | */
55 | test('My address should be already filled in at the checkout',{ tag: ['@checkout', '@hot']}, async ({page}) => {
56 | let signInLink = page.getByRole('link', { name: UIReference.credentials.loginButtonLabel });
57 | let addressField = page.getByLabel(UIReference.newAddress.streetAddressLabel);
58 | let addressAlreadyAdded = false;
59 |
60 | if(await signInLink.isVisible()) {
61 | throw new Error(`Sign in link found, user is not logged in. Please check the test setup.`);
62 | }
63 |
64 | // name field should NOT be on the page
65 | await expect(page.getByLabel(UIReference.personalInformation.firstNameLabel)).toBeHidden();
66 |
67 | if(await addressField.isVisible()) {
68 | if(!addressAlreadyAdded){
69 | // Address field is visible and addressalreadyAdded is not true, so we need to add an address to the account.
70 | const accountPage = new AccountPage(page);
71 | await accountPage.addNewAddress();
72 | } else {
73 | throw new Error(`Address field is visible even though an address has been added to the account.`);
74 | }
75 | }
76 |
77 | // expect to see radio button to select existing address
78 | let shippingRadioButton = page.locator(UIReference.checkout.shippingAddressRadioLocator).first();
79 | await expect(shippingRadioButton, 'Radio button to select address should be visible').toBeVisible();
80 |
81 | });
82 |
83 |
84 | /**
85 | * @feature Place order for simple product
86 | * @scenario User places an order for a simple product
87 | * @given I have a product in my cart
88 | * @and I am on any page
89 | * @when I navigate to the checkout
90 | * @and I fill in the required fields
91 | * @and I click the button to place my order
92 | * @then I should see a confirmation that my order has been placed
93 | * @and a order number should be created and show to me
94 | */
95 | test('Place order for simple product',{ tag: ['@simple-product-order', '@hot'],}, async ({page}, testInfo) => {
96 | const checkoutPage = new CheckoutPage(page);
97 | let orderNumber = await checkoutPage.placeOrder();
98 | testInfo.annotations.push({ type: 'Order number', description: `${orderNumber}` });
99 | });
100 | });
101 |
102 | test.describe('Checkout (guest)', () => {
103 | /**
104 | * @feature Discount Code
105 | * @scenario User adds a discount code to their cart
106 | * @given I have a product in my cart
107 | * @and I am on my cart page
108 | * @when I click on the 'add discount code' button
109 | * @then I fill in a code
110 | * @and I click on 'apply code'
111 | * @then I should see a confirmation that my code has been added
112 | * @and the code should be visible in the cart
113 | * @and a discount should be applied to the product
114 | */
115 | test('Add coupon code in checkout',{ tag: ['@checkout', '@coupon-code', '@cold']}, async ({page, browserName}) => {
116 | const checkout = new CheckoutPage(page);
117 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
118 | let discountCode = process.env[`MAGENTO_COUPON_CODE_${browserEngine}`];
119 |
120 | if(!discountCode) {
121 | throw new Error(`MAGENTO_COUPON_CODE_${browserEngine} appears to not be set in .env file. Value reported: ${discountCode}`);
122 | }
123 |
124 | await checkout.applyDiscountCodeCheckout(discountCode);
125 | });
126 |
127 | test('Verify price calculations in checkout', { tag: ['@checkout', '@price-calculation'] }, async ({ page }) => {
128 | const productPage = new ProductPage(page);
129 | const checkoutPage = new CheckoutPage(page);
130 |
131 | // Add product to cart and go to checkout
132 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug);
133 | await page.goto(slugs.checkout.checkoutSlug);
134 |
135 | // Select shipping method to trigger price calculations
136 | await checkoutPage.shippingMethodOptionFixed.check();
137 |
138 | // Wait for totals to update
139 | await page.waitForFunction(() => {
140 | const element = document.querySelector('.magewire\\.messenger');
141 | return element && getComputedStyle(element).height === '0px';
142 | });
143 |
144 | // Get all price components using the verifyPriceCalculations method from the CheckoutPage fixture
145 | await checkoutPage.verifyPriceCalculations();
146 | });
147 |
148 | /**
149 | * @feature Remove discount code from checkout
150 | * @scenario User has added a discount code, then removes it
151 | * @given I have a product in my cart
152 | * @and I am on the checkout page
153 | * @when I add a discount code
154 | * @then I should see a notification
155 | * @and the code should be visible in the cart
156 | * @and a discount should be applied to a product
157 | * @when I click the 'cancel coupon' button
158 | * @then I should see a notification the discount has been removed
159 | * @and the discount should no longer be visible.
160 | */
161 |
162 | test('Remove coupon code from checkout',{ tag: ['@checkout', '@coupon-code', '@cold']}, async ({page, browserName}) => {
163 | const checkout = new CheckoutPage(page);
164 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
165 | let discountCode = process.env[`MAGENTO_COUPON_CODE_${browserEngine}`];
166 |
167 | if(!discountCode) {
168 | throw new Error(`MAGENTO_COUPON_CODE appears to not be set in .env file. Value reported: ${discountCode}`);
169 | }
170 |
171 | await checkout.applyDiscountCodeCheckout(discountCode);
172 | await checkout.removeDiscountCode();
173 | });
174 |
175 | /**
176 | * @feature Incorrect discount code check
177 | * @scenario The user provides an incorrect discount code, the system should reflect that
178 | * @given I have a product in my cart
179 | * @and I am on the cart page
180 | * @when I enter a wrong discount code
181 | * @then I should get a notification that the code did not work.
182 | */
183 |
184 | test('Using an invalid coupon code should give an error',{ tag: ['@checkout', '@coupon-code', '@cold'] }, async ({page}) => {
185 | const checkout = new CheckoutPage(page);
186 | await checkout.enterWrongCouponCode("incorrect discount code");
187 | });
188 |
189 | /**
190 | * @feature Payment Method Selection
191 | * @scenario Guest user selects different payment methods during checkout
192 | * @given I have a product in my cart
193 | * @and I am on the checkout page as a guest
194 | * @when I select a payment method
195 | * @and I complete the checkout process
196 | * @then I should see a confirmation that my order has been placed
197 | * @and a order number should be created and shown to me
198 | */
199 | test('Guest can select different payment methods', { tag: ['@checkout', '@payment-methods', '@cold'] }, async ({ page }) => {
200 | const checkoutPage = new CheckoutPage(page);
201 |
202 | // Test with check/money order payment
203 | await test.step('Place order with check/money order payment', async () => {
204 | await page.goto(slugs.checkout.checkoutSlug);
205 | await checkoutPage.fillShippingAddress();
206 | await checkoutPage.shippingMethodOptionFixed.check();
207 | await checkoutPage.selectPaymentMethod('check');
208 | let orderNumber = await checkoutPage.placeOrder();
209 | expect(orderNumber, 'Order number should be generated and returned').toBeTruthy();
210 | });
211 | });
212 | });
213 |
--------------------------------------------------------------------------------
/tests/base/compare.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 | import {ProductPage} from './fixtures/product.page';
3 | import ComparePage from './fixtures/compare.page';
4 | import {LoginPage} from './fixtures/login.page';
5 |
6 | import slugs from './config/slugs.json';
7 | import UIReference from './config/element-identifiers/element-identifiers.json';
8 | import outcomeMarker from './config/outcome-markers/outcome-markers.json';
9 |
10 | // TODO: Create a fixture for this
11 | test.beforeEach('Add 2 products to compare, then navigate to comparison page', async ({ page }) => {
12 | await test.step('Add products to compare', async () =>{
13 | const productPage = new ProductPage(page);
14 | await productPage.addProductToCompare(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug);
15 | await productPage.addProductToCompare(UIReference.productPage.secondSimpleProducTitle, slugs.productpage.secondSimpleProductSlug);
16 | });
17 |
18 | await test.step('Navigate to product comparison page', async () =>{
19 | await page.goto(slugs.productpage.productComparisonSlug);
20 | await expect(page.getByRole('heading', { name: UIReference.comparePage.comparisonPageTitleText }).locator('span')).toBeVisible();
21 | });
22 | });
23 |
24 | /**
25 | * @feature Add product to cart from the comparison page
26 | * @scenario User adds a product to their cart from the comparison page
27 | * @given I am on the comparison page and have a product in my comparison list
28 | * @when I click the 'add to cart' button
29 | * @then I should see a notification that the product has been added
30 | */
31 | test('Add_product_to_cart_from_comparison_page',{ tag: ['@comparison-page', '@cold']}, async ({page}) => {
32 | const comparePage = new ComparePage(page);
33 | await comparePage.addToCart(UIReference.productPage.simpleProductTitle);
34 | });
35 |
36 | /**
37 | * @feature A product cannot be added to the wishlist without being logged in
38 | * @scenario User attempt to add a product to their wishlist from the comparison page
39 | * @given I am on the comparison page and have a product in my comparison list
40 | * @when I click the 'add to wishlist' button
41 | * @then I should see an error message
42 | */
43 | test('Guests_can_not_add_a_product_to_their_wishlist',{ tag: ['@comparison-page', '@cold']}, async ({page}) => {
44 | const errorMessage = page.locator(UIReference.general.errorMessageLocator);
45 | let productNotWishlistedNotificationText = outcomeMarker.comparePage.productNotWishlistedNotificationText;
46 | let addToWishlistButton = page.getByLabel(`${UIReference.comparePage.addToWishListLabel} ${UIReference.productPage.simpleProductTitle}`);
47 | await addToWishlistButton.click();
48 | await errorMessage.waitFor();
49 | await expect(page.getByText(productNotWishlistedNotificationText)).toBeVisible();
50 |
51 | await expect(page.url()).toContain(slugs.account.loginSlug);
52 | });
53 |
54 | /**
55 | * @feature Add product to wishlist from the comparison page
56 | * @scenario User adds a product to their wishlist from the comparison page
57 | * @given I am on the comparison page and have a product in my comparison list
58 | * @and I am logged in
59 | * @when I click the 'add to wishlist' button
60 | * @then I should see a notification that the product has been added to my wishlist
61 | */
62 | test('Add_product_to_wishlist_from_comparison_page',{ tag: ['@comparison-page', '@hot']}, async ({page, browserName}) => {
63 | await test.step('Log in with account', async () =>{
64 | const loginPage = new LoginPage(page);
65 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
66 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
67 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
68 |
69 | if(!emailInputValue || !passwordInputValue) {
70 | 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.");
71 | }
72 |
73 | await loginPage.login(emailInputValue, passwordInputValue);
74 | });
75 |
76 | await test.step('Add product to compare', async () =>{
77 | const productPage = new ProductPage(page);
78 | await page.goto(slugs.productpage.productComparisonSlug);
79 | await productPage.addProductToCompare(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug);
80 | });
81 |
82 | await test.step('Add product to wishlist', async () =>{
83 | const comparePage = new ComparePage(page);
84 | await comparePage.addToWishList(UIReference.productPage.simpleProductTitle);
85 |
86 | //TODO: Also remove the product for clear testing environment)
87 | });
88 | });
89 |
90 |
91 |
92 | test.afterEach('Remove products from compare', async ({ page }) => {
93 | // ensure we are on the right page
94 | await page.goto(slugs.productpage.productComparisonSlug);
95 |
96 | page.on('dialog', dialog => dialog.accept());
97 | const comparePage = new ComparePage(page);
98 | await comparePage.removeProductFromCompare(UIReference.productPage.simpleProductTitle);
99 | await comparePage.removeProductFromCompare(UIReference.productPage.secondSimpleProducTitle);
100 | });
--------------------------------------------------------------------------------
/tests/base/config/element-identifiers/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 | "removeLabel": "Remove",
153 | "successMessageLocator": "div.message.success"
154 | },
155 | "homePage": {
156 | "homePageTitleText": "Hyvä Themes"
157 | },
158 | "magentoAdminPage": {
159 | "loginButtonLabel": "Sign In",
160 | "navigation": {
161 | "marketingButtonLabel": "Marketing",
162 | "storesButtonLabel": "Stores"
163 | },
164 | "passwordFieldLabel": "Password",
165 | "subNavigation": {
166 | "cartPriceRulesButtonLabel": "Cart Price Rules",
167 | "configurationButtonLabel": "Configuration"
168 | },
169 | "usernameFieldLabel": "Username"
170 | },
171 | "mainMenu": {
172 | "miniCartLabel": "Toggle minicart",
173 | "myAccountButtonLabel": "My Account",
174 | "myAccountLogoutItem": "Sign Out"
175 | },
176 | "miniCart": {
177 | "checkOutButtonLabel": "Checkout",
178 | "editProductIconLabel": "Edit product",
179 | "minicartButtonLocator": "#menu-cart-icon",
180 | "minicartAmountBubbleLocator": "#menu-cart-icon > span",
181 | "minicartPriceFieldClass": ".price-excluding-tax .minicart-price .price",
182 | "miniCartToggleLabelEmpty": "Cart is empty",
183 | "miniCartToggleLabelMultiItem": "items",
184 | "miniCartToggleLabelOneItem": "1 item",
185 | "miniCartToggleLabelPrefix": "Toggle minicart,",
186 | "productQuantityFieldLabel": "Quantity",
187 | "removeProductIconLabel": "Remove product",
188 | "toCartLinkLabel": "View and Edit Cart"
189 | },
190 | "personalInformation": {
191 | "changePasswordCheckLabel": "Change Password",
192 | "firstNameLabel": "First Name",
193 | "lastNameLabel": "Last Name"
194 | },
195 | "wishListPage": {
196 | "wishListItemGridLabel": "#wishlist-view-form",
197 | "updateCompareListButtonLabel": "Update Wish List"
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/tests/base/config/input-values/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 | "editedAddress": {
23 | "editCityValue": "Pallet Town",
24 | "editCompanyNameValue": "Pokémon",
25 | "editfirstNameValue": "Ash",
26 | "editLastNameValue": "Ketchum",
27 | "editStateValue": "Kansas",
28 | "editStreetAddressValue": "House on the left",
29 | "editZipCodeValue": "00151"
30 | },
31 | "firstAddress": {
32 | "firstCityValue": "Testing Valley",
33 | "firstCompanyNameValue": "ACME Company",
34 | "firstNonDefaultCountry": "Netherlands",
35 | "firstPhoneNumberValue": "0622000000",
36 | "firstProvinceValue": "Idaho",
37 | "firstStreetAddressValue": "Testingstreet 1",
38 | "firstZipCodeValue": "12345"
39 | },
40 | "payment": {
41 | "creditCard": {
42 | "number": "4111111111111111",
43 | "expiry": "12/25",
44 | "cvv": "123",
45 | "name": "Test User"
46 | }
47 | },
48 | "secondAddress": {
49 | "secondCityValue": "Little Whinging",
50 | "secondCompanyNameValue": "Hogwarts",
51 | "secondNonDefaultCountry": "United Kingdom",
52 | "secondPhoneNumberValue": "0620081998",
53 | "secondProvinceValue": "South Dakota",
54 | "secondStreetAddressValue": "Under the Stairs, 4 Privet Drive",
55 | "secondZipCodeValue": "67890"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/tests/base/config/outcome-markers/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 # 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 | "wishListPage": {
57 | "wishListAddedNotification": "has been added to your Wish List."
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/base/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 | "changePasswordSlug": "/customer/account/edit/changepass/1/",
8 | "createAccountSlug": "/customer/account/create",
9 | "loginSlug": "/customer/account/login"
10 | },
11 | "cart": {
12 | "cartProductChangeSlug": "/cart/configure/",
13 | "cartSlug": "/checkout/cart/"
14 | },
15 | "categoryPage": {
16 | "categorySlug": "/women.html"
17 | },
18 | "checkout": {
19 | "checkoutSlug": "/checkout/"
20 | },
21 | "contact": {
22 | "contactSlug": "/contact"
23 | },
24 | "productpage": {
25 | "configurableProductSlug": "/inez-full-zip-jacket.html",
26 | "productComparisonSlug": "/catalog/product_compare/index/",
27 | "secondSimpleProductSlug": "/aim-analog-watch.html",
28 | "simpleProductSlug": "/push-it-messenger-bag.html"
29 | },
30 | "wishlist": {
31 | "wishListRegex": ".*wishlist.*"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/tests/base/config/test-toggles.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "general": {
3 | "setup": false,
4 | "pageHealthCheck": false
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/tests/base/contact.spec.ts:
--------------------------------------------------------------------------------
1 | import {test} from '@playwright/test';
2 | import { ContactPage } from './fixtures/contact.page';
3 |
4 | /**
5 | * @feature Magento 2 Contact Form
6 | * @scenario User fills in the contact form and sends a message
7 | * @given I om any Magento 2 page
8 | * @when I navigate to the contact page
9 | * @and I fill in the required fields
10 | * @when I click the button to send the form
11 | * @then I should see a notification my message has been sent
12 | * @and the fields should be empty again.
13 | */
14 | test('I can send a message through the contact form',{ tag: ['@contact-form', '@cold']}, async ({page}) => {
15 | const contactPage = new ContactPage(page);
16 | await contactPage.fillOutForm();
17 | });
--------------------------------------------------------------------------------
/tests/base/fixtures/account.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 | import {faker} from '@faker-js/faker';
3 |
4 | import UIReference from '../config/element-identifiers/element-identifiers.json';
5 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
6 | import slugs from '../config/slugs.json';
7 |
8 | export class AccountPage {
9 | readonly page: Page;
10 | readonly accountDashboardTitle: Locator;
11 | readonly firstNameField: Locator;
12 | readonly lastNameField: Locator;
13 | readonly phoneNumberField: Locator;
14 | readonly streetAddressField: Locator;
15 | readonly zipCodeField: Locator;
16 | readonly cityField: Locator;
17 | readonly countrySelectorField: Locator;
18 | readonly stateSelectorField: Locator;
19 | readonly saveAddressButton: Locator;
20 | readonly addNewAddressButton: Locator;
21 | readonly deleteAddressButton: Locator;
22 | readonly editAddressButton: Locator;
23 | readonly changePasswordCheck: Locator;
24 | readonly currentPasswordField: Locator;
25 | readonly newPasswordField: Locator;
26 | readonly confirmNewPasswordField: Locator;
27 | readonly genericSaveButton: Locator;
28 | readonly accountCreationFirstNameField: Locator;
29 | readonly accountCreationLastNameField: Locator;
30 | readonly accountCreationEmailField: Locator;
31 | readonly accountCreationPasswordField: Locator;
32 | readonly accountCreationPasswordRepeatField: Locator;
33 | readonly accountCreationConfirmButton: Locator;
34 |
35 |
36 | constructor(page: Page){
37 | this.page = page;
38 | this.accountDashboardTitle = page.getByRole('heading', { name: UIReference.accountDashboard.accountDashboardTitleLabel });
39 | this.firstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel);
40 | this.lastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel);
41 | this.phoneNumberField = page.getByLabel(UIReference.newAddress.phoneNumberLabel);
42 | this.streetAddressField = page.getByLabel(UIReference.newAddress.streetAddressLabel, {exact:true});
43 | this.zipCodeField = page.getByLabel(UIReference.newAddress.zipCodeLabel);
44 | this.cityField = page.getByLabel(UIReference.newAddress.cityNameLabel);
45 | this.countrySelectorField = page.getByLabel(UIReference.newAddress.countryLabel);
46 | this.stateSelectorField = page.getByLabel(UIReference.newAddress.provinceSelectLabel).filter({hasText: UIReference.newAddress.provinceSelectFilterLabel});
47 | this.saveAddressButton = page.getByRole('button',{name: UIReference.newAddress.saveAdressButton});
48 |
49 | // Account Information elements
50 | this.changePasswordCheck = page.getByRole('checkbox', {name: UIReference.personalInformation.changePasswordCheckLabel});
51 | this.currentPasswordField = page.getByLabel(UIReference.credentials.currentPasswordFieldLabel);
52 | this.newPasswordField = page.getByLabel(UIReference.credentials.newPasswordFieldLabel, {exact:true});
53 | this.confirmNewPasswordField = page.getByLabel(UIReference.credentials.newPasswordConfirmFieldLabel);
54 | this.genericSaveButton = page.getByRole('button', { name: UIReference.general.genericSaveButtonLabel });
55 |
56 | // Account Creation elements
57 | this.accountCreationFirstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel);
58 | this.accountCreationLastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel);
59 | this.accountCreationEmailField = page.getByLabel(UIReference.credentials.emailFieldLabel, { exact: true});
60 | this.accountCreationPasswordField = page.getByLabel(UIReference.credentials.passwordFieldLabel, { exact: true });
61 | this.accountCreationPasswordRepeatField = page.getByLabel(UIReference.credentials.passwordConfirmFieldLabel);
62 | this.accountCreationConfirmButton = page.getByRole('button', {name: UIReference.accountCreation.createAccountButtonLabel});
63 |
64 | // Address Book elements
65 | this.addNewAddressButton = page.getByRole('button',{name: UIReference.accountDashboard.addAddressButtonLabel});
66 | this.deleteAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.addressDeleteIconButton}).first();
67 | this.editAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.editAddressIconButton}).first();
68 | }
69 |
70 | async addNewAddress(){
71 | let addressAddedNotification = outcomeMarker.address.newAddressAddedNotifcation;
72 | let streetName = faker.location.streetAddress();
73 |
74 | // Name should be filled in automatically.
75 | await expect(this.firstNameField).not.toBeEmpty();
76 | await expect(this.lastNameField).not.toBeEmpty();
77 |
78 | await this.phoneNumberField.fill(faker.phone.number());
79 | await this.streetAddressField.fill(streetName);
80 | await this.zipCodeField.fill(faker.location.zipCode());
81 | await this.cityField.fill(faker.location.city());
82 | await this.stateSelectorField.selectOption(faker.location.state());
83 | await this.saveAddressButton.click();
84 | await this.page.waitForLoadState();
85 |
86 | await expect.soft(this.page.getByText(addressAddedNotification)).toBeVisible();
87 | await expect(this.page.getByText(streetName).last()).toBeVisible();
88 | }
89 |
90 |
91 | async editExistingAddress(){
92 | // the notification for a modified address is the same as the notification for a new address.
93 | let addressModifiedNotification = outcomeMarker.address.newAddressAddedNotifcation;
94 | let streetName = faker.location.streetAddress();
95 |
96 | await this.editAddressButton.click();
97 |
98 | // Name should be filled in automatically, but editable.
99 | await expect(this.firstNameField).not.toBeEmpty();
100 | await expect(this.lastNameField).not.toBeEmpty();
101 |
102 | await this.firstNameField.fill(faker.person.firstName());
103 | await this.lastNameField.fill(faker.person.lastName());
104 | await this.streetAddressField.fill(streetName);
105 | await this.zipCodeField.fill(faker.location.zipCode());
106 | await this.cityField.fill(faker.location.city());
107 | await this.stateSelectorField.selectOption(faker.location.state());
108 |
109 | await this.saveAddressButton.click();
110 | await this.page.waitForLoadState();
111 |
112 | await expect.soft(this.page.getByText(addressModifiedNotification)).toBeVisible();
113 | await expect(this.page.getByText(streetName).last()).toBeVisible();
114 | }
115 |
116 |
117 | async deleteFirstAddressFromAddressBook(){
118 | let addressDeletedNotification = outcomeMarker.address.addressDeletedNotification;
119 | let addressBookSection = this.page.locator(UIReference.accountDashboard.addressBookArea);
120 |
121 | // Dialog function to click confirm
122 | this.page.on('dialog', async (dialog) => {
123 | if (dialog.type() === 'confirm') {
124 | await dialog.accept();
125 | }
126 | });
127 |
128 | // Grab addresses from the address book, split the string and grab the address to be deleted.
129 | let addressBookArray = await addressBookSection.allInnerTexts();
130 | let arraySplit = addressBookArray[0].split('\n');
131 | let addressToBeDeleted = arraySplit[7];
132 |
133 | await this.deleteAddressButton.click();
134 | await this.page.waitForLoadState();
135 |
136 | await expect(this.page.getByText(addressDeletedNotification)).toBeVisible();
137 | await expect(addressBookSection, `${addressToBeDeleted} should not be visible`).not.toContainText(addressToBeDeleted);
138 | }
139 |
140 | async updatePassword(currentPassword:string, newPassword: string){
141 | let passwordUpdatedNotification = outcomeMarker.account.changedPasswordNotificationText;
142 | await this.changePasswordCheck.check();
143 |
144 | await this.currentPasswordField.fill(currentPassword);
145 | await this.newPasswordField.fill(newPassword);
146 | await this.confirmNewPasswordField.fill(newPassword);
147 |
148 | await this.genericSaveButton.click();
149 | await this.page.waitForLoadState();
150 |
151 | await expect(this.page.getByText(passwordUpdatedNotification)).toBeVisible();
152 | }
153 |
154 | async deleteAllAddresses() {
155 | let addressDeletedNotification = outcomeMarker.address.addressDeletedNotification;
156 |
157 | this.page.on('dialog', async (dialog) => {
158 | if (dialog.type() === 'confirm') {
159 | await dialog.accept();
160 | }
161 | });
162 |
163 | while (await this.deleteAddressButton.isVisible()) {
164 | await this.deleteAddressButton.click();
165 | await this.page.waitForLoadState();
166 |
167 | await expect.soft(this.page.getByText(addressDeletedNotification)).toBeVisible();
168 | }
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/tests/base/fixtures/base.page.ts:
--------------------------------------------------------------------------------
1 | import { Page } from '@playwright/test';
2 |
3 | export class BasePage {
4 | readonly page: Page;
5 |
6 | constructor(page: Page) {
7 | this.page = page;
8 | }
9 |
10 | /**
11 | * Waits for all Magewire requests to complete
12 | * This includes both UI indicators and actual network requests
13 | */
14 | async waitForMagewireRequests(): Promise {
15 | // Wait for the Magewire messenger element to disappear or have 0 height
16 | await this.page.waitForFunction(() => {
17 | const element = document.querySelector('.magewire\\.messenger');
18 | return element && getComputedStyle(element).height === '0px';
19 | }, { timeout: 30000 });
20 |
21 | // Additionally wait for any pending Magewire network requests to complete
22 | await this.page.waitForFunction(() => {
23 | return !window.magewire || !(window.magewire as any).processing;
24 | }, { timeout: 30000 });
25 |
26 | // Small additional delay to ensure DOM updates are complete
27 | await this.page.waitForTimeout(500);
28 | }
29 |
30 | /**
31 | * Waits for a specific network request to complete
32 | * @param urlPattern - URL pattern to match (e.g., '*/magewire/*')
33 | */
34 | async waitForNetworkRequest(urlPattern: string): Promise {
35 | await this.page.waitForResponse(
36 | response => response.url().includes(urlPattern) && response.status() === 200,
37 | { timeout: 30000 }
38 | );
39 |
40 | // Small additional delay to ensure DOM updates are complete
41 | await this.page.waitForTimeout(500);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tests/base/fixtures/cart.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 |
3 | import UIReference from '../config/element-identifiers/element-identifiers.json';
4 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
5 |
6 | export class CartPage {
7 | readonly page: Page;
8 | readonly showDiscountButton: Locator;
9 | readonly applyDiscountButton: Locator;
10 | productPagePrice: string;
11 | productPageAmount: string;
12 | productQuantityInCheckout: string;
13 | productPriceInCheckout: string;
14 |
15 | constructor(page: Page) {
16 | this.page = page;
17 | this.showDiscountButton = this.page.getByRole('button', { name: UIReference.cart.showDiscountFormButtonLabel });
18 | }
19 |
20 | async changeProductQuantity(amount: string){
21 | const productRow = this.page.getByRole('row', {name: UIReference.productPage.simpleProductTitle});
22 | let currentQuantity = await productRow.getByLabel(UIReference.cart.cartQuantityLabel).inputValue();
23 |
24 | if(currentQuantity == amount){
25 | // quantity is the same as amount, therefore we change amount to ensure test can continue.
26 | amount = '3';
27 | }
28 |
29 | await productRow.getByLabel(UIReference.cart.cartQuantityLabel).fill(amount);
30 | let subTotalBeforeUpdate = await productRow.getByText(UIReference.general.genericPriceSymbol).last().innerText();
31 |
32 | await this.page.getByRole('button', { name: UIReference.cart.updateShoppingCartButtonLabel }).click();
33 | await this.page.reload();
34 |
35 | currentQuantity = await productRow.getByLabel(UIReference.cart.cartQuantityLabel).inputValue();
36 |
37 | // Last $ to get the Subtotal
38 | let subTotalAfterUpdate = await productRow.getByText(UIReference.general.genericPriceSymbol).last().innerText();
39 |
40 | // Assertions: subtotals are different, and quantity field is still the new amount.
41 | expect(subTotalAfterUpdate, `Subtotals should not be the same`).not.toEqual(subTotalBeforeUpdate);
42 | expect(currentQuantity, `quantity should be the new value`).toEqual(amount);
43 | }
44 |
45 | // ==============================================
46 | // Product-related methods
47 | // ==============================================
48 |
49 | async removeProduct(productTitle: string){
50 | let removeButton = this.page.getByLabel(`${UIReference.general.removeLabel} ${productTitle}`);
51 | await removeButton.click();
52 | await this.page.waitForLoadState();
53 | await expect(removeButton,`Button to remove specified product is not visible in the cart`).toBeHidden();
54 |
55 | // Expect product to no longer be visible in the cart
56 | await expect (this.page.getByRole('cell', { name: productTitle }), `Product is not visible in cart`).toBeHidden();
57 | }
58 |
59 | // ==============================================
60 | // Discount-related methods
61 | // ==============================================
62 | async applyDiscountCode(code: string){
63 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){
64 | // discount field is not open.
65 | await this.showDiscountButton.click();
66 | }
67 |
68 | let applyDiscoundButton = this.page.getByRole('button', {name: UIReference.cart.applyDiscountButtonLabel, exact:true});
69 | let discountField = this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel);
70 | await discountField.fill(code);
71 | await applyDiscoundButton.click();
72 | await this.page.waitForLoadState();
73 |
74 | await expect.soft(this.page.getByText(`${outcomeMarker.cart.discountAppliedNotification} "${code}"`),`Notification that discount code ${code} has been applied`).toBeVisible();
75 | await expect(this.page.getByText(outcomeMarker.cart.priceReducedSymbols),`'- $' should be visible on the page`).toBeVisible();
76 | //Close message to prevent difficulties with other tests.
77 | await this.page.getByLabel(UIReference.general.closeMessageLabel).click();
78 | }
79 |
80 | async removeDiscountCode(){
81 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){
82 | // discount field is not open.
83 | await this.showDiscountButton.click();
84 | }
85 |
86 | let cancelCouponButton = this.page.getByRole('button', {name: UIReference.cart.cancelCouponButtonLabel});
87 | await cancelCouponButton.click();
88 | await this.page.waitForLoadState();
89 |
90 | await expect.soft(this.page.getByText(outcomeMarker.cart.discountRemovedNotification),`Notification should be visible`).toBeVisible();
91 | await expect(this.page.getByText(outcomeMarker.cart.priceReducedSymbols),`'- $' should not be on the page`).toBeHidden();
92 | }
93 |
94 | async enterWrongCouponCode(code: string){
95 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){
96 | // discount field is not open.
97 | await this.showDiscountButton.click();
98 | }
99 |
100 | let applyDiscoundButton = this.page.getByRole('button', {name: UIReference.cart.applyDiscountButtonLabel, exact:true});
101 | let discountField = this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel);
102 | await discountField.fill(code);
103 | await applyDiscoundButton.click();
104 | await this.page.waitForLoadState();
105 |
106 | let incorrectNotification = `${outcomeMarker.cart.incorrectCouponCodeNotificationOne} "${code}" ${outcomeMarker.cart.incorrectCouponCodeNotificationTwo}`;
107 |
108 | //Assertions: notification that code was incorrect & discount code field is still editable
109 | await expect.soft(this.page.getByText(incorrectNotification), `Code should not work`).toBeVisible();
110 | await expect(discountField).toBeEditable();
111 | }
112 |
113 |
114 | // ==============================================
115 | // Additional methods
116 | // ==============================================
117 |
118 | async getCheckoutValues(productName:string, pricePDP:string, amountPDP:string){
119 | // Open minicart based on amount of products in cart
120 | let cartItemAmount = await this.page.locator(UIReference.miniCart.minicartAmountBubbleLocator).count();
121 | if(cartItemAmount == 1) {
122 | await this.page.getByLabel(`${UIReference.checkout.openCartButtonLabel} ${cartItemAmount} ${UIReference.checkout.openCartButtonLabelCont}`).click();
123 | } else {
124 | await this.page.getByLabel(`${UIReference.checkout.openCartButtonLabel} ${cartItemAmount} ${UIReference.checkout.openCartButtonLabelContMultiple}`).click();
125 | }
126 |
127 | // Get values from checkout page
128 | let productInCheckout = this.page.locator(UIReference.checkout.cartDetailsLocator).filter({ hasText: productName }).nth(1);
129 | this.productPriceInCheckout = await productInCheckout.getByText(UIReference.general.genericPriceSymbol).innerText();
130 | this.productPriceInCheckout = this.productPriceInCheckout.trim();
131 | let productImage = this.page.locator(UIReference.checkout.cartDetailsLocator)
132 | .filter({ has: this.page.getByRole('img', { name: productName })});
133 | this.productQuantityInCheckout = await productImage.locator('> span').innerText();
134 |
135 | return [this.productPriceInCheckout, this.productQuantityInCheckout];
136 | }
137 |
138 | async calculateProductPricesAndCompare(pricePDP: string, amountPDP:string, priceCheckout:string, amountCheckout:string){
139 | // perform magic to calculate price * amount and mold it into the correct form again
140 | pricePDP = pricePDP.replace(UIReference.general.genericPriceSymbol,'');
141 | let pricePDPInt = Number(pricePDP);
142 | let quantityPDPInt = +amountPDP;
143 | let calculatedPricePDP = `${UIReference.general.genericPriceSymbol}` + (pricePDPInt * quantityPDPInt).toFixed(2);
144 |
145 | expect(amountPDP,`Amount on PDP (${amountPDP}) equals amount in checkout (${amountCheckout})`).toEqual(amountCheckout);
146 | expect(calculatedPricePDP, `Price * qty on PDP (${calculatedPricePDP}) equals price * qty in checkout (${priceCheckout})`).toEqual(priceCheckout);
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/tests/base/fixtures/category.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 |
3 | import UIReference from '../config/element-identifiers/element-identifiers.json';
4 |
5 | import slugs from '../config/slugs.json';
6 |
7 | export default class CategoryPage {
8 | readonly page:Page;
9 | categoryPageTitle: Locator;
10 |
11 | constructor(page: Page) {
12 | this.page = page;
13 | this.categoryPageTitle = this.page.getByRole('heading', { name: UIReference.categoryPage.categoryPageTitleText });
14 | }
15 |
16 | async goToCategoryPage(){
17 | await this.page.goto(slugs.categoryPage.categorySlug);
18 | // Wait for the first filter option to be visible
19 | const firstFilterOption = this.page.locator(UIReference.categoryPage.firstFilterOptionLocator);
20 | await firstFilterOption.waitFor();
21 |
22 | this.page.waitForLoadState();
23 | await expect(this.categoryPageTitle).toBeVisible();
24 | }
25 |
26 | async filterOnSize(browser:string){
27 | const sizeFilterButton = this.page.getByRole('button', {name: UIReference.categoryPage.sizeFilterButtonLabel});
28 | const sizeLButton = this.page.locator(UIReference.categoryPage.sizeLButtonLocator);
29 | const removeActiveFilterLink = this.page.getByRole('link', {name: UIReference.categoryPage.removeActiveFilterButtonLabel}).first();
30 | const amountOfItemsBeforeFilter = parseInt(await this.page.locator(UIReference.categoryPage.itemsOnPageAmountLocator).last().innerText());
31 |
32 |
33 | /**
34 | * BROWSER_WORKAROUND
35 | * The size filter seems to auto-close in Firefox and Webkit.
36 | * Therefore, we open it manually.
37 | */
38 | if(browser !== 'chromium'){
39 | await sizeFilterButton.click();
40 | }
41 |
42 | await sizeLButton.click();
43 |
44 | const sizeFilterRegex = new RegExp(`\\?size=L$`);
45 | await this.page.waitForURL(sizeFilterRegex);
46 |
47 | const amountOfItemsAfterFilter = parseInt(await this.page.locator(UIReference.categoryPage.itemsOnPageAmountLocator).last().innerText());
48 | await expect(removeActiveFilterLink, 'Trash button to remove filter is visible').toBeVisible();
49 | expect(amountOfItemsAfterFilter, `Amount of items shown with filter (${amountOfItemsAfterFilter}) is less than without (${amountOfItemsBeforeFilter})`).toBeLessThan(amountOfItemsBeforeFilter);
50 |
51 | }
52 |
53 | async sortProducts(attribute:string){
54 | const sortButton = this.page.getByLabel(UIReference.categoryPage.sortByButtonLabel);
55 | await sortButton.selectOption(attribute);
56 | const sortRegex = new RegExp(`\\?product_list_order=${attribute}$`);
57 | await this.page.waitForURL(sortRegex);
58 |
59 | const selectedValue = await this.page.$eval(UIReference.categoryPage.sortByButtonLocator, sel => sel.value);
60 |
61 | // sortButton should now display attribute
62 | expect(selectedValue, `Sort button should now display ${attribute}`).toEqual(attribute);
63 | // URL now has ?product_list_order=${attribute}
64 | expect(this.page.url(), `URL should contain ?product_list_order=${attribute}`).toContain(`product_list_order=${attribute}`);
65 | }
66 |
67 |
68 | async showMoreProducts(){
69 | const itemsPerPageButton = this.page.getByLabel(UIReference.categoryPage.itemsPerPageButtonLabel);
70 | const productGrid = this.page.locator(UIReference.categoryPage.productGridLocator);
71 |
72 | await itemsPerPageButton.selectOption('36');
73 | const itemsRegex = /\?product_list_limit=36$/;
74 | await this.page.waitForURL(itemsRegex);
75 |
76 | const amountOfItems = await productGrid.locator('li').count();
77 |
78 | expect(this.page.url(), `URL should contain ?product_list_limit=36`).toContain(`?product_list_limit=36`);
79 | expect(amountOfItems, `Amount of items on the page should be 36`).toBe(36);
80 | }
81 |
82 | async switchView(){
83 | const viewSwitcher = this.page.getByLabel(UIReference.categoryPage.viewSwitchLabel, {exact: true}).locator(UIReference.categoryPage.activeViewLocator);
84 | const activeView = await viewSwitcher.getAttribute('title');
85 |
86 | if(activeView == 'Grid'){
87 | await this.page.getByLabel(UIReference.categoryPage.viewListLabel).click();
88 | } else {
89 | await this.page.getByLabel(UIReference.categoryPage.viewGridLabel).click();
90 | }
91 |
92 | const viewRegex = /\?product_list_mode=list$/;
93 | await this.page.waitForURL(viewRegex);
94 |
95 | const newActiveView = await viewSwitcher.getAttribute('title');
96 | expect(newActiveView, `View (now ${newActiveView}) should be switched (old: ${activeView})`).not.toEqual(activeView);
97 | expect(this.page.url(),`URL should contain ?product_list_mode=${newActiveView?.toLowerCase()}`).toContain(`?product_list_mode=${newActiveView?.toLowerCase()}`);
98 | }
99 | }
--------------------------------------------------------------------------------
/tests/base/fixtures/checkout.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 | import {faker} from '@faker-js/faker';
3 | import { BasePage } from './base.page';
4 |
5 | import UIReference from '../config/element-identifiers/element-identifiers.json';
6 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
7 | import slugs from '../config/slugs.json';
8 | import inputvalues from '../config/input-values/input-values.json';
9 |
10 | export class CheckoutPage extends BasePage {
11 |
12 | readonly shippingMethodOptionFixed: Locator;
13 | readonly paymentMethodOptionCheck: Locator;
14 | readonly showDiscountFormButton: Locator;
15 | readonly placeOrderButton: Locator;
16 | readonly continueShoppingButton: Locator;
17 | readonly subtotalElement: Locator;
18 | readonly shippingElement: Locator;
19 | readonly taxElement: Locator;
20 | readonly grandTotalElement: Locator;
21 | readonly paymentMethodOptionCreditCard: Locator;
22 | readonly paymentMethodOptionPaypal: Locator;
23 | readonly creditCardNumberField: Locator;
24 | readonly creditCardExpiryField: Locator;
25 | readonly creditCardCVVField: Locator;
26 | readonly creditCardNameField: Locator;
27 |
28 | constructor(page: Page){
29 | super(page);
30 | this.shippingMethodOptionFixed = this.page.getByLabel(UIReference.checkout.shippingMethodFixedLabel);
31 | this.paymentMethodOptionCheck = this.page.getByLabel(UIReference.checkout.paymentOptionCheckLabel);
32 | this.showDiscountFormButton = this.page.getByRole('button', {name: UIReference.checkout.openDiscountFormLabel});
33 | this.placeOrderButton = this.page.getByRole('button', { name: UIReference.checkout.placeOrderButtonLabel });
34 | this.continueShoppingButton = this.page.getByRole('link', { name: UIReference.checkout.continueShoppingLabel });
35 | this.subtotalElement = page.getByText('Subtotal $');
36 | this.shippingElement = page.getByText('Shipping & Handling (Flat Rate - Fixed) $');
37 | this.taxElement = page.getByText('Tax $');
38 | this.grandTotalElement = page.getByText('Grand Total $');
39 | this.paymentMethodOptionCreditCard = this.page.getByLabel(UIReference.checkout.paymentOptionCreditCardLabel);
40 | this.paymentMethodOptionPaypal = this.page.getByLabel(UIReference.checkout.paymentOptionPaypalLabel);
41 | this.creditCardNumberField = this.page.getByLabel(UIReference.checkout.creditCardNumberLabel);
42 | this.creditCardExpiryField = this.page.getByLabel(UIReference.checkout.creditCardExpiryLabel);
43 | this.creditCardCVVField = this.page.getByLabel(UIReference.checkout.creditCardCVVLabel);
44 | this.creditCardNameField = this.page.getByLabel(UIReference.checkout.creditCardNameLabel);
45 | }
46 |
47 | // ==============================================
48 | // Order-related methods
49 | // ==============================================
50 |
51 | async placeOrder(){
52 | let orderPlacedNotification = outcomeMarker.checkout.orderPlacedNotification;
53 |
54 | // If we're not already on the checkout page, go there
55 | if (!this.page.url().includes(slugs.checkout.checkoutSlug)) {
56 | await this.page.goto(slugs.checkout.checkoutSlug);
57 | }
58 |
59 | // If shipping method is not selected, select it
60 | if (!(await this.shippingMethodOptionFixed.isChecked())) {
61 | await this.shippingMethodOptionFixed.check();
62 | await this.waitForMagewireRequests();
63 | }
64 |
65 | await this.paymentMethodOptionCheck.check();
66 | await this.waitForMagewireRequests();
67 |
68 | await this.placeOrderButton.click();
69 | await this.waitForMagewireRequests();
70 |
71 | await expect.soft(this.page.getByText(orderPlacedNotification)).toBeVisible();
72 | let orderNumber = await this.page.locator('p').filter({ hasText: outcomeMarker.checkout.orderPlacedNumberText });
73 |
74 | await expect(this.continueShoppingButton, `${outcomeMarker.checkout.orderPlacedNumberText} ${orderNumber}`).toBeVisible();
75 | return orderNumber;
76 | }
77 |
78 |
79 | // ==============================================
80 | // Discount-related methods
81 | // ==============================================
82 |
83 | async applyDiscountCodeCheckout(code: string){
84 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){
85 | // discount field is not open.
86 | await this.showDiscountFormButton.click();
87 | await this.waitForMagewireRequests();
88 | }
89 |
90 | if(await this.page.getByText(outcomeMarker.cart.priceReducedSymbols).isVisible()){
91 | // discount is already active.
92 | let cancelCouponButton = this.page.getByRole('button', { name: UIReference.checkout.cancelDiscountButtonLabel });
93 | await cancelCouponButton.click();
94 | await this.waitForMagewireRequests();
95 | }
96 |
97 | let applyCouponCheckoutButton = this.page.getByRole('button', { name: UIReference.checkout.applyDiscountButtonLabel });
98 | let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel);
99 |
100 | await checkoutDiscountField.fill(code);
101 | await applyCouponCheckoutButton.click();
102 | await this.waitForMagewireRequests();
103 |
104 | await expect.soft(this.page.getByText(`${outcomeMarker.checkout.couponAppliedNotification}`),`Notification that discount code ${code} has been applied`).toBeVisible({timeout: 30000});
105 | await expect(this.page.getByText(outcomeMarker.checkout.checkoutPriceReducedSymbol),`'-$' should be visible on the page`).toBeVisible();
106 | }
107 |
108 | async enterWrongCouponCode(code: string){
109 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){
110 | // discount field is not open.
111 | await this.showDiscountFormButton.click();
112 | await this.waitForMagewireRequests();
113 | }
114 |
115 | let applyCouponCheckoutButton = this.page.getByRole('button', { name: UIReference.checkout.applyDiscountButtonLabel });
116 | let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel);
117 | await checkoutDiscountField.fill(code);
118 | await applyCouponCheckoutButton.click();
119 | await this.waitForMagewireRequests();
120 |
121 | await expect.soft(this.page.getByText(outcomeMarker.checkout.incorrectDiscountNotification), `Code should not work`).toBeVisible();
122 | await expect(checkoutDiscountField).toBeEditable();
123 | }
124 |
125 | async removeDiscountCode(){
126 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){
127 | // discount field is not open.
128 | await this.showDiscountFormButton.click();
129 | await this.waitForMagewireRequests();
130 | }
131 |
132 | let cancelCouponButton = this.page.getByRole('button', {name: UIReference.cart.cancelCouponButtonLabel});
133 | await cancelCouponButton.click();
134 | await this.waitForMagewireRequests();
135 |
136 | await expect.soft(this.page.getByText(outcomeMarker.checkout.couponRemovedNotification),`Notification should be visible`).toBeVisible();
137 | await expect(this.page.getByText(outcomeMarker.checkout.checkoutPriceReducedSymbol),`'-$' should not be on the page`).toBeHidden();
138 |
139 | let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel);
140 | await expect(checkoutDiscountField).toBeEditable();
141 | }
142 |
143 | // ==============================================
144 | // Price summary methods
145 | // ==============================================
146 |
147 | async getPriceValue(element: Locator): Promise {
148 | const priceText = await element.innerText();
149 | // Extract just the price part after the $ symbol
150 | const match = priceText.match(/\$\s*([\d.]+)/);
151 | return match ? parseFloat(match[1]) : 0;
152 | }
153 |
154 | async verifyPriceCalculations() {
155 | const subtotal = await this.getPriceValue(this.subtotalElement);
156 | const shipping = await this.getPriceValue(this.shippingElement);
157 | const tax = await this.getPriceValue(this.taxElement);
158 | const grandTotal = await this.getPriceValue(this.grandTotalElement);
159 |
160 | const calculatedTotal = +(subtotal + shipping + tax).toFixed(2);
161 |
162 | expect(subtotal, `Subtotal (${subtotal}) should be greater than 0`).toBeGreaterThan(0);
163 | expect(shipping, `Shipping cost (${shipping}) should be greater than 0`).toBeGreaterThan(0);
164 | // Enable when tax settings are set.
165 | //expect(tax, `Tax (${tax}) should be greater than 0`).toBeGreaterThan(0);
166 | expect(grandTotal, `Grand total (${grandTotal}) should equal calculated total (${calculatedTotal})`).toBe(calculatedTotal);
167 | }
168 |
169 | async selectPaymentMethod(method: 'check' | 'creditcard' | 'paypal'): Promise {
170 | switch(method) {
171 | case 'check':
172 | await this.paymentMethodOptionCheck.check();
173 | break;
174 | case 'creditcard':
175 | await this.paymentMethodOptionCreditCard.check();
176 | // Fill credit card details
177 | await this.creditCardNumberField.fill(inputvalues.payment?.creditCard?.number || '4111111111111111');
178 | await this.creditCardExpiryField.fill(inputvalues.payment?.creditCard?.expiry || '12/25');
179 | await this.creditCardCVVField.fill(inputvalues.payment?.creditCard?.cvv || '123');
180 | await this.creditCardNameField.fill(inputvalues.payment?.creditCard?.name || 'Test User');
181 | break;
182 | case 'paypal':
183 | await this.paymentMethodOptionPaypal.check();
184 | break;
185 | }
186 |
187 | await this.waitForMagewireRequests();
188 | }
189 |
190 | async fillShippingAddress() {
191 | // Fill required shipping address fields
192 | await this.page.getByLabel(UIReference.credentials.emailCheckoutFieldLabel, { exact: true }).fill(faker.internet.email());
193 | await this.page.getByLabel(UIReference.personalInformation.firstNameLabel).fill(faker.person.firstName());
194 | await this.page.getByLabel(UIReference.personalInformation.lastNameLabel).fill(faker.person.lastName());
195 | await this.page.getByLabel(UIReference.newAddress.streetAddressLabel).first().fill(faker.location.streetAddress());
196 | await this.page.getByLabel(UIReference.newAddress.zipCodeLabel).fill(faker.location.zipCode());
197 | await this.page.getByLabel(UIReference.newAddress.cityNameLabel).fill(faker.location.city());
198 | await this.page.getByLabel(UIReference.newAddress.phoneNumberLabel).fill(faker.phone.number());
199 |
200 | // Select country (if needed)
201 | // await this.page.getByLabel('Country').selectOption('US');
202 |
203 | // Select state
204 | await this.page.getByLabel('State/Province').selectOption(faker.location.state());
205 |
206 | // Wait for any Magewire updates
207 | await this.waitForMagewireRequests();
208 | }
209 | }
210 |
--------------------------------------------------------------------------------
/tests/base/fixtures/compare.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 |
3 | import slugs from '../config/slugs.json';
4 |
5 | import UIReference from '../config/element-identifiers/element-identifiers.json';
6 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
7 |
8 | export default class ComparePage {
9 | page: Page;
10 |
11 | constructor(page: Page) {
12 | this.page = page;
13 | }
14 |
15 | async removeProductFromCompare(product:string){
16 | let comparisonPageEmptyText = this.page.getByText(UIReference.comparePage.comparisonPageEmptyText);
17 | // if the comparison page is empty, we can't remove anything
18 | if (await comparisonPageEmptyText.isVisible()) {
19 | return;
20 | }
21 |
22 | let removeFromCompareButton = this.page.getByLabel(`${UIReference.comparePage.removeCompareLabel} ${product}`);
23 | let productRemovedNotification = this.page.getByText(`${outcomeMarker.comparePage.productRemovedNotificationTextOne} ${product} ${outcomeMarker.comparePage.productRemovedNotificationTextTwo}`);
24 | await removeFromCompareButton.click();
25 | await expect(productRemovedNotification).toBeVisible();
26 | }
27 |
28 | async addToCart(product:string){
29 | const successMessage = this.page.locator(UIReference.general.successMessageLocator);
30 | let productAddedNotification = this.page.getByText(`${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`);
31 | let addToCartbutton = this.page.getByLabel(`${UIReference.general.addToCartLabel} ${product}`);
32 |
33 | await addToCartbutton.click();
34 | await successMessage.waitFor();
35 | await expect(productAddedNotification).toBeVisible();
36 | }
37 |
38 | async addToWishList(product:string){
39 | const successMessage = this.page.locator(UIReference.general.successMessageLocator);
40 | let addToWishlistButton = this.page.getByLabel(`${UIReference.comparePage.addToWishListLabel} ${product}`);
41 | let productAddedNotification = this.page.getByText(`${product} ${outcomeMarker.wishListPage.wishListAddedNotification}`);
42 |
43 | await addToWishlistButton.click();
44 | await successMessage.waitFor();
45 | }
46 | }
--------------------------------------------------------------------------------
/tests/base/fixtures/contact.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 | import {faker} from '@faker-js/faker';
3 |
4 | import UIReference from '../config/element-identifiers/element-identifiers.json';
5 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
6 | import slugs from '../config/slugs.json';
7 |
8 | export class ContactPage {
9 | readonly page: Page;
10 | readonly nameField: Locator;
11 | readonly emailField: Locator;
12 | readonly messageField: Locator;
13 | readonly sendFormButton: Locator;
14 |
15 | constructor(page: Page){
16 | this.page = page;
17 | this.nameField = this.page.getByLabel(UIReference.credentials.nameFieldLabel);
18 | this.emailField = this.page.getByPlaceholder('Email', { exact: true });
19 | this.messageField = this.page.locator(UIReference.contactPage.messageFieldSelector);
20 | this.sendFormButton = this.page.getByRole('button', { name: UIReference.general.genericSubmitButtonLabel });
21 | }
22 |
23 | async fillOutForm(){
24 | await this.page.goto(slugs.contact.contactSlug);
25 | let messageSentConfirmationText = outcomeMarker.contactPage.messageSentConfirmationText;
26 |
27 | // Add a wait for the form to be visible
28 | await this.nameField.waitFor({state: 'visible', timeout: 10000});
29 |
30 | await this.nameField.fill(faker.person.firstName());
31 | await this.emailField.fill(faker.internet.email());
32 | await this.messageField.fill(faker.lorem.paragraph());
33 |
34 | await this.sendFormButton.click();
35 |
36 | await expect(this.page.getByText(messageSentConfirmationText)).toBeVisible();
37 | await expect(this.nameField, 'name should be empty now').toBeEmpty();
38 | await expect(this.emailField, 'email should be empty now').toBeEmpty();
39 | await expect(this.messageField, 'message should be empty now').toBeEmpty();
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/tests/base/fixtures/home.page.ts:
--------------------------------------------------------------------------------
1 | import {type Locator, type Page} from '@playwright/test';
2 |
3 | import UIReference from '../config/element-identifiers/element-identifiers.json';
4 |
5 | export class HomePage {
6 |
7 | readonly page: Page;
8 | buyProductButton: Locator;
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 |
--------------------------------------------------------------------------------
/tests/base/fixtures/login.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 |
3 | import slugs from '../config/slugs.json';
4 | import UIReference from '../config/element-identifiers/element-identifiers.json';
5 |
6 | export 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 |
--------------------------------------------------------------------------------
/tests/base/fixtures/magentoAdmin.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 |
3 | import UIReference from '../config/element-identifiers/element-identifiers.json';
4 | import values from '../config/input-values/input-values.json';
5 |
6 | export 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(values.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: values.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: values.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: values.captcha.captchaDisabled });
103 | await this.page.getByRole('button', { name: UIReference.configurationPage.saveConfigButtonLabel }).click();
104 | await this.page.waitForLoadState('networkidle');
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/tests/base/fixtures/mainmenu.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 |
3 | import slugs from '../config/slugs.json';
4 | import UIReference from '../config/element-identifiers/element-identifiers.json';
5 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
6 |
7 |
8 | export class MainMenuPage {
9 | readonly page: Page;
10 | readonly mainMenuAccountButton: Locator;
11 | readonly mainMenuMiniCartButton: Locator;
12 | readonly mainMenuMyAccountItem: Locator;
13 | readonly mainMenuLogoutItem: Locator;
14 |
15 | constructor(page: Page) {
16 | this.page = page;
17 | this.mainMenuAccountButton = page.getByLabel(UIReference.mainMenu.myAccountButtonLabel);
18 | this.mainMenuMiniCartButton = page.getByLabel(UIReference.mainMenu.miniCartLabel);
19 | this.mainMenuLogoutItem = page.getByTitle(UIReference.mainMenu.myAccountLogoutItem);
20 | this.mainMenuMyAccountItem = page.getByTitle(UIReference.mainMenu.myAccountButtonLabel);
21 | }
22 |
23 | async gotoMyAccount(){
24 | await this.page.goto(slugs.productpage.simpleProductSlug);
25 | await this.mainMenuAccountButton.click();
26 | await this.mainMenuMyAccountItem.click();
27 |
28 | await expect(this.page.getByRole('heading', { name: UIReference.accountDashboard.accountDashboardTitleLabel })).toBeVisible();
29 | }
30 |
31 | async gotoAddressBook() {
32 | // create function to navigate to Address Book through the header menu links
33 | }
34 |
35 | async openMiniCart() {
36 | // await this.page.reload();
37 | // FIREFOX_WORKAROUND: wait for 3 seconds to allow minicart to be updated.
38 | await this.page.waitForTimeout(3000);
39 | const cartAmountBubble = this.mainMenuMiniCartButton.locator('span');
40 | cartAmountBubble.waitFor();
41 | const amountInCart = await cartAmountBubble.innerText();
42 |
43 | // waitFor is added to ensure the minicart button is visible before clicking, mostly as a fix for Firefox.
44 | // await this.mainMenuMiniCartButton.waitFor();
45 |
46 | await this.mainMenuMiniCartButton.click();
47 |
48 | let miniCartDrawer = this.page.locator("#cart-drawer-title");
49 | await expect(miniCartDrawer.getByText(outcomeMarker.miniCart.miniCartTitle)).toBeVisible();
50 | }
51 |
52 | async logout(){
53 | await this.page.goto(slugs.account.accountOverviewSlug);
54 | await this.mainMenuAccountButton.click();
55 | await this.mainMenuLogoutItem.click();
56 |
57 | //assertions: notification that user is logged out & logout button no longer visible
58 | await expect(this.page.getByText(outcomeMarker.logout.logoutConfirmationText, { exact: true })).toBeVisible();
59 | await expect(this.mainMenuLogoutItem).toBeHidden();
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/tests/base/fixtures/minicart.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 |
3 | import UIReference from '../config/element-identifiers/element-identifiers.json';
4 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
5 | import slugs from '../config/slugs.json';
6 |
7 | export class MiniCartPage {
8 | readonly page: Page;
9 | readonly toCheckoutButton: Locator;
10 | readonly toCartButton: Locator;
11 | readonly editProductButton: Locator;
12 | readonly productQuantityField: Locator;
13 | readonly updateItemButton: Locator;
14 | readonly cartQuantityField: Locator;
15 | readonly priceOnPDP: Locator;
16 | readonly priceInMinicart: Locator;
17 |
18 | constructor(page: Page) {
19 | this.page = page;
20 | this.toCheckoutButton = page.getByRole('link', { name: UIReference.miniCart.checkOutButtonLabel });
21 | this.toCartButton = page.getByRole('link', { name: UIReference.miniCart.toCartLinkLabel });
22 | this.editProductButton = page.getByLabel(UIReference.miniCart.editProductIconLabel);
23 | this.productQuantityField = page.getByLabel(UIReference.miniCart.productQuantityFieldLabel);
24 | this.updateItemButton = page.getByRole('button', { name: UIReference.cart.updateItemButtonLabel });
25 | this.priceOnPDP = page.getByLabel(UIReference.general.genericPriceLabel).getByText(UIReference.general.genericPriceSymbol);
26 | this.priceInMinicart = page.getByText(UIReference.general.genericPriceSymbol).first();
27 | }
28 |
29 | async goToCheckout(){
30 | await this.toCheckoutButton.click();
31 | await expect(this.page).toHaveURL(new RegExp(`${slugs.checkout.checkoutSlug}.*`));
32 | }
33 |
34 | async goToCart(){
35 | await this.toCartButton.click();
36 | await expect(this.page).toHaveURL(new RegExp(`${slugs.cart.cartSlug}.*`));
37 | }
38 |
39 | async removeProductFromMinicart(product: string) {
40 | let productRemovedNotification = outcomeMarker.miniCart.productRemovedConfirmation;
41 | let removeProductMiniCartButton = this.page.getByLabel(`${UIReference.miniCart.removeProductIconLabel} "${UIReference.productPage.simpleProductTitle}"`);
42 | await removeProductMiniCartButton.click();
43 | await expect.soft(this.page.getByText(productRemovedNotification)).toBeVisible();
44 | await expect(removeProductMiniCartButton).toBeHidden();
45 | }
46 |
47 | async updateProduct(amount: string){
48 | let productQuantityChangedNotification = outcomeMarker.miniCart.productQuantityChangedConfirmation;
49 | await this.editProductButton.click();
50 | await expect(this.page).toHaveURL(new RegExp(`${slugs.cart.cartProductChangeSlug}.*`));
51 |
52 | await this.productQuantityField.fill(amount);
53 | await this.updateItemButton.click();
54 | await expect.soft(this.page.getByText(productQuantityChangedNotification)).toBeVisible();
55 |
56 | let productQuantityInCart = await this.page.getByLabel(UIReference.cart.cartQuantityLabel).first().inputValue();
57 | expect(productQuantityInCart).toBe(amount);
58 | }
59 |
60 | async checkPriceWithProductPage() {
61 | const priceOnPage = await this.page.locator(UIReference.productPage.simpleProductPrice).first().innerText();
62 | const productTitle = await this.page.getByRole('heading', { level : 1}).innerText();
63 | const productListing = this.page.locator('div').filter({hasText: productTitle});
64 | const priceInMinicart = await productListing.locator(UIReference.miniCart.minicartPriceFieldClass).first().textContent();
65 | //expect(priceOnPage).toBe(priceInMinicart);
66 | expect(priceOnPage, `Expect these prices to be the same: priceOnpage: ${priceOnPage} and priceInMinicart: ${priceInMinicart}`).toBe(priceInMinicart);
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/tests/base/fixtures/newsletter.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 | import UIReference from '../config/element-identifiers/element-identifiers.json';
3 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
4 |
5 | export class NewsletterSubscriptionPage {
6 | readonly page: Page;
7 | readonly newsletterCheckElement: Locator;
8 | readonly saveSubscriptionsButton: Locator;
9 |
10 | constructor(page: Page) {
11 | this.page = page;
12 | this.newsletterCheckElement = page.getByLabel(UIReference.newsletterSubscriptions.generalSubscriptionCheckLabel);
13 | this.saveSubscriptionsButton = page.getByRole('button', {name:UIReference.newsletterSubscriptions.saveSubscriptionsButton});
14 | }
15 |
16 | async updateNewsletterSubscription(){
17 |
18 | if(await this.newsletterCheckElement.isChecked()) {
19 | // user is already subscribed, test runs unsubscribe
20 | var subscriptionUpdatedNotification = outcomeMarker.account.newsletterRemovedNotification;
21 |
22 | await this.newsletterCheckElement.uncheck();
23 | await this.saveSubscriptionsButton.click();
24 |
25 | var subscribed = false;
26 |
27 | } else {
28 | // user is not yet subscribed, test runs subscribe
29 | subscriptionUpdatedNotification = outcomeMarker.account.newsletterSavedNotification;
30 |
31 | await this.newsletterCheckElement.check();
32 | await this.saveSubscriptionsButton.click();
33 |
34 | subscribed = true;
35 | }
36 |
37 | await expect(this.page.getByText(subscriptionUpdatedNotification)).toBeVisible();
38 | return subscribed;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/tests/base/fixtures/product.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 |
3 | import slugs from '../config/slugs.json';
4 |
5 | import UIReference from '../config/element-identifiers/element-identifiers.json';
6 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
7 |
8 | export class ProductPage {
9 | readonly page: Page;
10 | simpleProductTitle: Locator;
11 | configurableProductTitle: Locator;
12 | addToCartButton: Locator;
13 | addToCompareButton: Locator;
14 | addToWishlistButton: Locator;
15 |
16 | constructor(page: Page) {
17 | this.page = page;
18 | this.addToCartButton = page.getByRole('button', { name: UIReference.productPage.addToCartButtonLocator });
19 | this.addToCompareButton = page.getByLabel(UIReference.productPage.addToCompareButtonLabel, { exact: true });
20 | this.addToWishlistButton = page.getByLabel(UIReference.productPage.addToWishlistButtonLabel, { exact: true });
21 | }
22 |
23 | // ==============================================
24 | // Productpage-related methods
25 | // ==============================================
26 |
27 | async addProductToCompare(product:string, url: string){
28 | let productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} product`;
29 | const successMessage = this.page.locator(UIReference.general.successMessageLocator);
30 |
31 | await this.page.goto(url);
32 | await this.addToCompareButton.click();
33 | await successMessage.waitFor();
34 | await expect(this.page.getByText(productAddedNotification)).toBeVisible();
35 |
36 | await this.page.goto(slugs.productpage.productComparisonSlug);
37 |
38 | // Assertion: a cell with the product name inside a cell with the product name should be visible
39 | await expect(this.page.getByRole('cell', {name: product}).getByText(product, {exact: true})).toBeVisible();
40 | }
41 |
42 | async addProductToWishlist(product:string, url: string){
43 | let addedToWishlistNotification = `${product} ${outcomeMarker.wishListPage.wishListAddedNotification}`;
44 | await this.page.goto(url);
45 | await this.addToWishlistButton.click();
46 |
47 | await this.page.waitForLoadState();
48 |
49 | let productNameInWishlist = this.page.locator(UIReference.wishListPage.wishListItemGridLabel).getByText(UIReference.productPage.simpleProductTitle, {exact: true});
50 |
51 | await expect(this.page).toHaveURL(new RegExp(slugs.wishlist.wishListRegex));
52 | await expect(this.page.getByText(addedToWishlistNotification)).toBeVisible();
53 | await expect(productNameInWishlist).toContainText(product);
54 | }
55 |
56 | async leaveProductReview(product:string, url: string){
57 | await this.page.goto(url);
58 |
59 | //TODO: Uncomment this and fix test once website is fixed
60 | /*
61 | await page.locator('#Rating_5_label path').click();
62 | await page.getByPlaceholder('Nickname*').click();
63 | await page.getByPlaceholder('Nickname*').fill('John');
64 | await page.getByPlaceholder('Nickname*').press('Tab');
65 | await page.getByPlaceholder('Summary*').click();
66 | await page.getByPlaceholder('Summary*').fill('A short paragraph');
67 | await page.getByPlaceholder('Review*').click();
68 | await page.getByPlaceholder('Review*').fill('Review message!');
69 | await page.getByRole('button', { name: 'Submit Review' }).click();
70 | await page.getByRole('img', { name: 'loader' }).click();
71 | */
72 | }
73 |
74 | async openLightboxAndScrollThrough(url: string){
75 | await this.page.goto(url);
76 | let fullScreenOpener = this.page.getByLabel(UIReference.productPage.fullScreenOpenLabel);
77 | let fullScreenCloser = this.page.getByLabel(UIReference.productPage.fullScreenCloseLabel);
78 | let thumbnails = this.page.getByRole('button', {name: UIReference.productPage.thumbnailImageLabel});
79 |
80 | await fullScreenOpener.click();
81 | await expect(fullScreenCloser).toBeVisible();
82 |
83 | for (const img of await thumbnails.all()) {
84 | await img.click();
85 | // wait for transition animation
86 | await this.page.waitForTimeout(500);
87 | await expect(img, `CSS class 'border-primary' appended to button`).toHaveClass(new RegExp(outcomeMarker.productPage.borderClassRegex));
88 | }
89 |
90 | await fullScreenCloser.click();
91 | await expect(fullScreenCloser).toBeHidden();
92 |
93 | }
94 |
95 | async changeReviewCountAndVerify(url: string) {
96 | await this.page.goto(url);
97 |
98 | // Get the default review count from URL or UI
99 | const initialUrl = this.page.url();
100 |
101 | // Find and click the review count selector
102 | const reviewCountSelector = this.page.getByLabel(UIReference.productPage.reviewCountLabel);
103 | await expect(reviewCountSelector).toBeVisible();
104 |
105 | // Select 20 reviews per page
106 | await reviewCountSelector.selectOption('20');
107 | await this.page.waitForURL(/.*limit=20.*/);
108 |
109 | // Verify URL contains the new limit
110 | const urlAfterFirstChange = this.page.url();
111 | expect(urlAfterFirstChange, 'URL should contain limit=20 parameter').toContain('limit=20');
112 | expect(urlAfterFirstChange, 'URL should have changed after selecting 20 items per page').not.toEqual(initialUrl);
113 |
114 | // Select 50 reviews per page
115 | await reviewCountSelector.selectOption('50');
116 | await this.page.waitForURL(/.*limit=50.*/);
117 |
118 | // Verify URL contains the new limit
119 | const urlAfterSecondChange = this.page.url();
120 | expect(urlAfterSecondChange, 'URL should contain limit=50 parameter').toContain('limit=50');
121 | expect(urlAfterSecondChange, 'URL should have changed after selecting 50 items per page').not.toEqual(urlAfterFirstChange);
122 | }
123 |
124 | // ==============================================
125 | // Cart-related methods
126 | // ==============================================
127 |
128 | async addSimpleProductToCart(product: string, url: string, quantity?: string) {
129 |
130 | await this.page.goto(url);
131 | this.simpleProductTitle = this.page.getByRole('heading', {name: product, exact:true});
132 | let productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`;
133 |
134 | this.simpleProductTitle = this.page.getByRole('heading', {name: product, exact:true});
135 | expect(await this.simpleProductTitle.innerText()).toEqual(product);
136 | await expect(this.simpleProductTitle.locator('span')).toBeVisible();
137 |
138 | if(quantity){
139 | // set quantity
140 | await this.page.getByLabel(UIReference.productPage.quantityFieldLabel).fill('2');
141 | }
142 |
143 | await this.addToCartButton.click();
144 | await expect(this.page.getByText(productAddedNotification)).toBeVisible();
145 | }
146 |
147 | async addConfigurableProductToCart(product: string, url:string, quantity?:string){
148 | await this.page.goto(url);
149 | this.configurableProductTitle = this.page.getByRole('heading', {name: product, exact:true});
150 | let productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`;
151 | const productOptions = this.page.locator(UIReference.productPage.configurableProductOptionForm);
152 |
153 | // loop through each radiogroup (product option) within the form
154 | for (const option of await productOptions.getByRole('radiogroup').all()) {
155 | await option.locator(UIReference.productPage.configurableProductOptionValue).first().check();
156 | }
157 |
158 | if(quantity){
159 | // set quantity
160 | await this.page.getByLabel(UIReference.productPage.quantityFieldLabel).fill('2');
161 | }
162 |
163 | await this.addToCartButton.click();
164 | let successMessage = this.page.locator(UIReference.general.successMessageLocator);
165 | await successMessage.waitFor();
166 | await expect(this.page.getByText(productAddedNotification)).toBeVisible();
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/tests/base/fixtures/register.page.ts:
--------------------------------------------------------------------------------
1 | import {expect, type Locator, type Page} from '@playwright/test';
2 |
3 | import slugs from '../config/slugs.json';
4 | import UIReference from '../config/element-identifiers/element-identifiers.json';
5 | import outcomeMarker from '../config/outcome-markers/outcome-markers.json';
6 |
7 | export class RegisterPage {
8 | readonly page: Page;
9 | readonly accountCreationFirstNameField: Locator;
10 | readonly accountCreationLastNameField: Locator;
11 | readonly accountCreationEmailField: Locator;
12 | readonly accountCreationPasswordField: Locator;
13 | readonly accountCreationPasswordRepeatField: Locator;
14 | readonly accountCreationConfirmButton: Locator;
15 |
16 | constructor(page: Page){
17 | this.page = page;
18 | this.accountCreationFirstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel);
19 | this.accountCreationLastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel);
20 | this.accountCreationEmailField = page.getByLabel(UIReference.credentials.emailFieldLabel, { exact: true});
21 | this.accountCreationPasswordField = page.getByLabel(UIReference.credentials.passwordFieldLabel, { exact: true });
22 | this.accountCreationPasswordRepeatField = page.getByLabel(UIReference.credentials.passwordConfirmFieldLabel);
23 | this.accountCreationConfirmButton = page.getByRole('button', {name: UIReference.accountCreation.createAccountButtonLabel});
24 | }
25 |
26 |
27 | async createNewAccount(firstName: string, lastName: string, email: string, password: string, muted: boolean = false){
28 | let accountInformationField = this.page.locator(UIReference.accountDashboard.accountInformationFieldLocator).first();
29 | await this.page.goto(slugs.account.createAccountSlug);
30 |
31 | await this.accountCreationFirstNameField.fill(firstName);
32 | await this.accountCreationLastNameField.fill(lastName);
33 | await this.accountCreationEmailField.fill(email);
34 | await this.accountCreationPasswordField.fill(password);
35 | await this.accountCreationPasswordRepeatField.fill(password);
36 | await this.accountCreationConfirmButton.click();
37 |
38 | if(!muted) {
39 | // Assertions: Account created notification, navigated to account page, email visible on page
40 | await expect(this.page.getByText(outcomeMarker.account.accountCreatedNotificationText), 'Account creation notification should be visible').toBeVisible();
41 | await expect(this.page, 'Should be redirected to account overview page').toHaveURL(slugs.account.accountOverviewSlug);
42 | await expect(accountInformationField, `Account information should contain email: ${email}`).toContainText(email);
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/base/healthcheck.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 | import toggles from './config/test-toggles.json';
3 |
4 | import UIReference from './config/element-identifiers/element-identifiers.json';
5 | import slugs from './config/slugs.json';
6 |
7 | if(toggles.general.pageHealthCheck === true) {
8 | test.only('Critical_pages_load_and_have_a_visible_title', {tag: '@cold'}, async ({ page }) => {
9 | await test.step('Homepage_returns_200', async () =>{
10 | let homepageURL = process.env.PLAYWRIGHT_BASE_URL;
11 |
12 | if(!homepageURL) {
13 | throw new Error("PLAYWRIGHT_BASE_URL has not been defined in the .env file.");
14 | }
15 |
16 | const homepageResponsePromise = page.waitForResponse(homepageURL);
17 | await page.goto(homepageURL);
18 | const homepageResponse = await homepageResponsePromise;
19 | expect(homepageResponse.status(), 'Homepage should return 200').toBe(200);
20 |
21 | await expect(page.getByRole('heading', { name: UIReference.homePage.homePageTitleText }), `Homepage has a visible title`).toBeVisible();
22 | });
23 |
24 | await test.step('PLP_returns_200', async () =>{
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(page.getByRole('heading', { name: UIReference.categoryPage.categoryPageTitleText }), `PLP has a visible title`).toBeVisible();
31 | });
32 |
33 | await test.step('PDP_returns_200', async () =>{
34 | const pdpResponsePromise = page.waitForResponse(slugs.productpage.simpleProductSlug);
35 | await page.goto(slugs.productpage.simpleProductSlug);
36 | const pdpResponse = await pdpResponsePromise;
37 | expect(pdpResponse.status(), 'PDP should return 200').toBe(200);
38 |
39 | await expect(page.getByRole('heading', {level:1, name: UIReference.productPage.simpleProductTitle}), `PLP has a visible title`).toBeVisible();
40 | });
41 |
42 | await test.step('Checkout_returns_200', async () =>{
43 |
44 | // First, check if there's an item in the cart
45 | const cartAmount = await page.locator(UIReference.miniCart.minicartButtonLocator).getAttribute('aria-label');
46 | if(!cartAmount){
47 | throw new Error("Cart amount is not visible.");
48 | }
49 |
50 | // Cart is empty, meaning the page a 302 is expected and the final URL is checkout/cart
51 | if(cartAmount.includes('empty')){
52 | const checkoutResponsePromise = page.waitForResponse(slugs.checkout.checkoutSlug);
53 | await page.goto(slugs.checkout.checkoutSlug);
54 | const checkoutResponse = await checkoutResponsePromise;
55 | expect(checkoutResponse.status(), `Cart empty, checkout should return 302`).toBe(302);
56 | expect(page.url(), `Cart empty, checkout should redirect to cart`).toContain(slugs.cart.cartSlug);
57 | await expect(page.getByRole('heading', { name: UIReference.cart.cartTitleText }), `Cart has a visible title`).toBeVisible();
58 | } else {
59 | const checkoutResponsePromise = page.waitForResponse(slugs.checkout.checkoutSlug);
60 | await page.goto(slugs.checkout.checkoutSlug);
61 | const checkoutResponse = await checkoutResponsePromise;
62 | expect(checkoutResponse.status(), `Checkout should return 200`).toBe(200);
63 |
64 | await expect(page.getByRole('button', { name: UIReference.checkout.placeOrderButtonLabel }), `Place Order button is visible`).toBeVisible();
65 | }
66 |
67 | });
68 | });
69 | }
--------------------------------------------------------------------------------
/tests/base/home.spec.ts:
--------------------------------------------------------------------------------
1 | import {test, expect} from '@playwright/test';
2 | import {MainMenuPage} from './fixtures/mainmenu.page';
3 | import {HomePage} from './fixtures/home.page';
4 |
5 | import outcomeMarker from './config/outcome-markers/outcome-markers.json';
6 |
7 | test('Add product on homepage to cart',{ tag: ['@homepage', '@cold']}, async ({page}) => {
8 | const homepage = new HomePage(page);
9 | const mainmenu = new MainMenuPage(page);
10 |
11 | await page.goto('');
12 | await homepage.addHomepageProductToCart();
13 | await mainmenu.openMiniCart();
14 | await expect(page.getByText('x ' + outcomeMarker.homePage.firstProductName), 'product should be visible in cart').toBeVisible();
15 | });
--------------------------------------------------------------------------------
/tests/base/login.spec.ts:
--------------------------------------------------------------------------------
1 | import {test as base, expect} from '@playwright/test';
2 | import {LoginPage} from './fixtures/login.page';
3 | import {MainMenuPage} from './fixtures/mainmenu.page';
4 | import inputvalues from './config/input-values/input-values.json';
5 |
6 | base('User can log in with valid credentials', {tag: '@hot'}, async ({page, browserName}) => {
7 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
8 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
9 |
10 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
11 |
12 | if(!emailInputValue || !passwordInputValue) {
13 | 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.");
14 | }
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 |
--------------------------------------------------------------------------------
/tests/base/mainmenu.spec.ts:
--------------------------------------------------------------------------------
1 | import {test} from '@playwright/test';
2 | import {LoginPage} from './fixtures/login.page';
3 | import {MainMenuPage} from './fixtures/mainmenu.page';
4 | import { ProductPage } from './fixtures/product.page';
5 |
6 | import UIReference from './config/element-identifiers/element-identifiers.json';
7 | import slugs from './config/slugs.json';
8 |
9 | // no resetting storageState, mainmenu has more functionalities when logged in.
10 |
11 | // Before each test, log in
12 | test.beforeEach(async ({ page, browserName }) => {
13 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
14 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
15 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
16 |
17 | if(!emailInputValue || !passwordInputValue) {
18 | 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.");
19 | }
20 |
21 | const loginPage = new LoginPage(page);
22 | await loginPage.login(emailInputValue, passwordInputValue);
23 | });
24 |
25 | /**
26 | * @feature Logout
27 | * @scenario The user can log out
28 | * @given I am logged in
29 | * @and I am on any Magento 2 page
30 | * @when I open the account menu
31 | * @and I click the Logout option
32 | * @then I should see a message confirming I am logged out
33 | */
34 | test('User can log out', { tag: ['@mainmenu', '@hot'] }, async ({page}) => {
35 | const mainMenu = new MainMenuPage(page);
36 | await mainMenu.logout();
37 | });
38 |
39 |
40 | test('Navigate to account page', { tag: ['@mainmenu', '@hot'] }, async ({page}) => {
41 | const mainMenu = new MainMenuPage(page);
42 | await mainMenu.gotoMyAccount();
43 | });
44 |
45 | test('Open the minicart', { tag: ['@mainmenu', '@cold'] }, async ({page}, testInfo) => {
46 | 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.`});
47 |
48 | const mainMenu = new MainMenuPage(page);
49 | const productPage = new ProductPage(page);
50 |
51 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug);
52 | await mainMenu.openMiniCart();
53 | });
54 |
--------------------------------------------------------------------------------
/tests/base/minicart.spec.ts:
--------------------------------------------------------------------------------
1 | import {test, expect} from '@playwright/test';
2 | import {MainMenuPage} from './fixtures/mainmenu.page';
3 | import {ProductPage} from './fixtures/product.page';
4 | import { MiniCartPage } from './fixtures/minicart.page';
5 |
6 | import slugs from './config/slugs.json';
7 | import UIReference from './config/element-identifiers/element-identifiers.json';
8 | import outcomeMarker from './config/outcome-markers/outcome-markers.json';
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, navigate 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, navigate 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 quantity of a product 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('Price on PDP is the same as price in Minicart',{ 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('Price configurable PDP is same as price in Minicart',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => {
130 | const miniCart = new MiniCartPage(page);
131 | await miniCart.checkPriceWithProductPage();
132 | });
133 | });
--------------------------------------------------------------------------------
/tests/base/product.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 |
3 | import {ProductPage} from './fixtures/product.page';
4 | import {LoginPage} from './fixtures/login.page';
5 |
6 | import slugs from './config/slugs.json';
7 | import UIReference from './config/element-identifiers/element-identifiers.json';
8 | import outcomeMarker from './config/outcome-markers/outcome-markers.json';
9 |
10 |
11 | test.describe('Product page tests',{ tag: '@product',}, () => {
12 | test('Add product to compare',{ tag: '@cold'}, async ({page}) => {
13 | const productPage = new ProductPage(page);
14 | await productPage.addProductToCompare(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug);
15 | });
16 |
17 | test('Add product to wishlist',{ tag: '@cold'}, async ({page, browserName}) => {
18 | await test.step('Log in with account', async () =>{
19 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
20 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
21 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
22 |
23 | if(!emailInputValue || !passwordInputValue) {
24 | 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.");
25 | }
26 |
27 | const loginPage = new LoginPage(page);
28 | await loginPage.login(emailInputValue, passwordInputValue);
29 | });
30 |
31 | await test.step('Add product to wishlist', async () =>{
32 | const productPage = new ProductPage(page);
33 | await productPage.addProductToWishlist(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug);
34 | });
35 | });
36 |
37 |
38 | test.fixme('Leave a product review (Test currently fails due to error on website)',{ tag: '@cold'}, async ({page}) => {
39 | // const productPage = new ProductPage(page);
40 | // await productPage.leaveProductReview(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug);
41 | });
42 |
43 | test('Open pictures in lightbox and scroll through', async ({page}) => {
44 | const productPage = new ProductPage(page);
45 | await productPage.openLightboxAndScrollThrough(slugs.productpage.configurableProductSlug);
46 | });
47 |
48 | test('Change number of reviews shown on product page', async ({page}) => {
49 | const productPage = new ProductPage(page);
50 | await productPage.changeReviewCountAndVerify(slugs.productpage.simpleProductSlug);
51 | });
52 | });
53 |
54 | test.describe('Simple product tests',{ tag: '@simple-product',}, () => {
55 | test.fixme('Simple tests will be added later', async ({ page }) => {});
56 | });
57 |
58 | test.describe('Configurable product tests',{ tag: '@conf-product',}, () => {
59 | test.fixme('Configurable tests will be added later', async ({ page }) => {});
60 | });
61 |
--------------------------------------------------------------------------------
/tests/base/register.spec.ts:
--------------------------------------------------------------------------------
1 | import {test} from '@playwright/test';
2 | import {RegisterPage} from './fixtures/register.page';
3 | import {faker} from '@faker-js/faker';
4 |
5 | import inputvalues from './config/input-values/input-values.json';
6 |
7 | // Reset storageState to ensure we're not logged in before running these tests.
8 | test.use({ storageState: { cookies: [], origins: [] } });
9 |
10 | /**
11 | * @feature Magento 2 Account Creation
12 | * @scenario The user creates an account on the website
13 | * @given I am on any Magento 2 page
14 | * @when I go to the account creation page
15 | * @and I fill in the required information correctly
16 | * @then I click the 'Create account' button
17 | * @then I should see a messsage confirming my account was created
18 | */
19 | test('User can register an account', { tag: ['@setup', '@hot'] }, async ({page, browserName}, testInfo) => {
20 | const registerPage = new RegisterPage(page);
21 |
22 | // Retrieve desired password from .env file
23 | const existingAccountPassword = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
24 | var firstName = faker.person.firstName();
25 | var lastName = faker.person.lastName();
26 |
27 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
28 | let randomNumber = Math.floor(Math.random() * 100);
29 | let emailHandle = inputvalues.accountCreation.emailHandleValue;
30 | let emailHost = inputvalues.accountCreation.emailHostValue;
31 | const accountEmail = `${emailHandle}${randomNumber}-${browserEngine}@${emailHost}`;
32 | //const accountEmail = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
33 |
34 | if (!accountEmail || !existingAccountPassword) {
35 | throw new Error(
36 | `MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine} or MAGENTO_EXISTING_ACCOUNT_PASSWORD is not defined in your .env file.`
37 | );
38 | }
39 | // end of browserNameEmailSection
40 |
41 | await registerPage.createNewAccount(firstName, lastName, accountEmail, existingAccountPassword);
42 | testInfo.annotations.push({ type: 'Notification: account created!', description: `Credentials used: ${accountEmail}, password: ${existingAccountPassword}` });
43 | });
44 |
--------------------------------------------------------------------------------
/tests/base/setup.spec.ts:
--------------------------------------------------------------------------------
1 | import { test as base } from '@playwright/test';
2 | import {faker} from '@faker-js/faker';
3 | import toggles from './config/test-toggles.json';
4 |
5 | import { MagentoAdminPage } from './fixtures/magentoAdmin.page';
6 | import { RegisterPage } from './fixtures/register.page';
7 | import { AccountPage } from './fixtures/account.page';
8 |
9 |
10 | import values from './config/input-values/input-values.json';
11 |
12 | import fs from 'fs';
13 | import path from 'path';
14 |
15 | /**
16 | * NOTE:
17 | * The first if-statement checks if we are running in CI.
18 | * if so, we always run the setup.
19 | * else, we check if the 'setup' test toggle in test-toggles.json has been set to true.
20 | * This is to ensure the tests always run in CI, but otherwise only run when requested.
21 | */
22 |
23 | const runSetupTests = (describeFn: typeof base.describe | typeof base.describe.only) => {
24 | describeFn('Setting up the testing environment', () => {
25 |
26 | base('Enable multiple Magento admin logins', {tag: '@setup',}, async ({ page, browserName }, testInfo) => {
27 | const magentoAdminUsername = process.env.MAGENTO_ADMIN_USERNAME;
28 | const magentoAdminPassword = process.env.MAGENTO_ADMIN_PASSWORD;
29 |
30 | if (!magentoAdminUsername || !magentoAdminPassword) {
31 | throw new Error("MAGENTO_ADMIN_USERNAME or MAGENTO_ADMIN_PASSWORD is not defined in your .env file.");
32 | }
33 |
34 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
35 |
36 | /**
37 | * Only enable multiple admin logins for Chromium browser.
38 | */
39 | if (browserEngine === "CHROMIUM") {
40 | const magentoAdminPage = new MagentoAdminPage(page);
41 | await magentoAdminPage.login(magentoAdminUsername, magentoAdminPassword);
42 | await magentoAdminPage.enableMultipleAdminLogins();
43 | } else {
44 | testInfo.skip(true, `Skipping because configuration is only needed once.`);
45 | }
46 | });
47 |
48 | base('Setup Magento environment for tests', {tag: '@setup',}, async ({ page, browserName }, testInfo) => {
49 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
50 | const setupCompleteVar = `SETUP_COMPLETE_${browserEngine}`;
51 | const isSetupComplete = process.env[setupCompleteVar];
52 |
53 | if(isSetupComplete === 'DONE') {
54 | testInfo.skip(true, `Skipping because configuration is only needed once.`);
55 | }
56 |
57 | await base.step(`Step 1: Perform actions`, async() =>{
58 | const magentoAdminUsername = process.env.MAGENTO_ADMIN_USERNAME;
59 | const magentoAdminPassword = process.env.MAGENTO_ADMIN_PASSWORD;
60 |
61 | if (!magentoAdminUsername || !magentoAdminPassword) {
62 | throw new Error("MAGENTO_ADMIN_USERNAME or MAGENTO_ADMIN_PASSWORD is not defined in your .env file.");
63 | }
64 |
65 | const magentoAdminPage = new MagentoAdminPage(page);
66 | await magentoAdminPage.login(magentoAdminUsername, magentoAdminPassword);
67 |
68 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
69 |
70 | const couponCode = process.env[`MAGENTO_COUPON_CODE_${browserEngine}`];
71 | if (!couponCode) {
72 | throw new Error(`MAGENTO_COUPON_CODE_${browserEngine} is not defined in your .env file.`);
73 | }
74 | await magentoAdminPage.addCartPriceRule(couponCode);
75 | await magentoAdminPage.disableLoginCaptcha();
76 |
77 | const registerPage = new RegisterPage(page);
78 |
79 | const accountEmail = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`];
80 | const accountPassword = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD;
81 |
82 | if (!accountEmail || !accountPassword) {
83 | throw new Error(
84 | `MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine} or MAGENTO_EXISTING_ACCOUNT_PASSWORD is not defined in your .env file.`
85 | );
86 | }
87 |
88 | await registerPage.createNewAccount(
89 | values.accountCreation.firstNameValue,
90 | values.accountCreation.lastNameValue,
91 | accountEmail,
92 | accountPassword,
93 | true
94 | );
95 | });
96 |
97 | await base.step(`Step 2: (optional) Update env file`, async() =>{
98 | if (process.env.CI === 'true') {
99 | console.log("Running in CI environment. Skipping .env update.");
100 | base.skip();
101 | }
102 |
103 | const envPath = path.resolve(__dirname, '../../.env');
104 | try {
105 | if (fs.existsSync(envPath)) {
106 | const envContent = fs.readFileSync(envPath, 'utf-8');
107 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN";
108 | if (!envContent.includes(`SETUP_COMPLETE_${browserEngine}='DONE'`)) {
109 | fs.appendFileSync(envPath, `\nSETUP_COMPLETE_${browserEngine}='DONE'`);
110 | console.log(`Environment setup completed successfully. 'SETUP_COMPLETE_${browserEngine}='DONE'' added to .env file.`);
111 | }
112 | // if (!envContent.includes(`SETUP_COMPLETE_${browserEngine}=true`)) {
113 | // fs.appendFileSync(envPath, `\nSETUP_COMPLETE_${browserEngine}=true`);
114 | // console.log(`Environment setup completed successfully. 'SETUP_COMPLETE_${browserEngine}=true' added to .env file.`);
115 | // }
116 | } else {
117 | throw new Error('.env file not found. Please ensure it exists in the root directory.');
118 | }
119 | } catch (error) {
120 | throw new Error(`Failed to update .env file: ${error.message}`);
121 | }
122 | });
123 | });
124 | });
125 | };
126 |
127 | if(process.env.CI) {
128 | /**
129 | * If we are running in CI, we want to run the setup tests,
130 | * But NOT exclusively.
131 | */
132 | runSetupTests(base.describe);
133 | } else {
134 | if(toggles.general.setup) {
135 | /**
136 | * This test is used to set up the testing environment.
137 | * It should only be run once, or when the environment needs to be reset.
138 | * It is skipped by default, but can be run by setting the 'general.setup' toggle to true.
139 | */
140 | runSetupTests(base.describe.only);
141 | }
142 | }
143 |
144 |
145 |
--------------------------------------------------------------------------------
/tests/base/types/magewire.d.ts:
--------------------------------------------------------------------------------
1 | interface Window {
2 | magewire?: {
3 | processing: boolean;
4 | [key: string]: any;
5 | };
6 | }
7 |
--------------------------------------------------------------------------------
/tests/custom/config/test-toggles.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "general": {
3 | "setup": false,
4 | "pageHealthCheck": false
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/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 | 'tests/base/config/element-identifiers/element-identifiers.json',
103 | 'tests/base/config/outcome-markers/outcome-markers.json'
104 | ];
105 |
106 | for (const jsonFile of jsonFiles) {
107 | const content = JSON.parse(fs.readFileSync(jsonFile, 'utf-8'));
108 | const translatedContent = translateObject(content, translations);
109 | fs.writeFileSync(jsonFile, JSON.stringify(translatedContent, null, 2));
110 | console.log(`Translated ${jsonFile}`);
111 | }
112 |
113 | console.log('Translation completed successfully!');
114 | } catch (error) {
115 | console.error('Error:', error.message);
116 | process.exit(1);
117 | }
--------------------------------------------------------------------------------