diff --git a/dev/tests/e2e/.env.example b/dev/tests/e2e/.env.example new file mode 100644 index 00000000000..cdba882e1fb --- /dev/null +++ b/dev/tests/e2e/.env.example @@ -0,0 +1,19 @@ +PLAYWRIGHT_BASE_URL=https://hyva-demo.elgentos.io/ +PLAYWRIGHT_PRODUCTION_URL=https://hyva-demo.elgentos.io/ +PLAYWRIGHT_REVIEW_URL=https://hyva-demo.elgentos.io/ + +MAGENTO_ADMIN_SLUG= +MAGENTO_ADMIN_USERNAME= +MAGENTO_ADMIN_PASSWORD= + +MAGENTO_THEME_LOCALE= +MAGENTO_NEW_ACCOUNT_PASSWORD= +MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM= +MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX= +MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT= +MAGENTO_EXISTING_ACCOUNT_PASSWORD= +MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD= + +MAGENTO_COUPON_CODE_CHROMIUM= +MAGENTO_COUPON_CODE_FIREFOX= +MAGENTO_COUPON_CODE_WEBKIT= \ No newline at end of file diff --git a/dev/tests/e2e/auth-storage/chromium-storage-state.json b/dev/tests/e2e/auth-storage/chromium-storage-state.json new file mode 100644 index 00000000000..f4ec35503c2 --- /dev/null +++ b/dev/tests/e2e/auth-storage/chromium-storage-state.json @@ -0,0 +1,4 @@ +{ + "cookies": [], + "origins": [] +} \ No newline at end of file diff --git a/dev/tests/e2e/auth-storage/firefox-storage-state.json b/dev/tests/e2e/auth-storage/firefox-storage-state.json new file mode 100644 index 00000000000..f4ec35503c2 --- /dev/null +++ b/dev/tests/e2e/auth-storage/firefox-storage-state.json @@ -0,0 +1,4 @@ +{ + "cookies": [], + "origins": [] +} \ No newline at end of file diff --git a/dev/tests/e2e/auth-storage/webkit-storage-state.json b/dev/tests/e2e/auth-storage/webkit-storage-state.json new file mode 100644 index 00000000000..f4ec35503c2 --- /dev/null +++ b/dev/tests/e2e/auth-storage/webkit-storage-state.json @@ -0,0 +1,4 @@ +{ + "cookies": [], + "origins": [] +} \ No newline at end of file diff --git a/dev/tests/e2e/base-tests/account.spec.ts b/dev/tests/e2e/base-tests/account.spec.ts new file mode 100644 index 00000000000..edb81333a4a --- /dev/null +++ b/dev/tests/e2e/base-tests/account.spec.ts @@ -0,0 +1,273 @@ +// @ts-check + +/** + * Copyright Elgentos. All rights reserved. + * https://elgentos.nl/ + * + * @fileoverview Various tests to check account functionality. + */ + +// Import test and expect from utils to ensure authenticated state. +import { test, expect } from '@utils/fixtures.utils'; +import { faker } from '@faker-js/faker'; + +import AccountPage from '@poms/frontend/account.page'; +import LoginPage from '@poms/frontend/login.page'; +import NewsletterSubscriptionPage from '@poms/frontend/newsletter.page'; + +import { requireEnv } from '@utils/env.utils'; +import ApiClient from '@utils/apiClient.utils'; +import { UIReference, outcomeMarker, slugs, inputValues} from '@config'; + +/** + * Test group: User credentials tests + */ +test.describe('User credentials tests (API-provisioned)', { annotation: +{type: 'Account Dashboard', description: 'Test for changing credentials using API-provisioned account'}, }, () => { + + let apiClient: ApiClient; + + // Ensure we don't use an authenticated state. + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeAll(async () => { + apiClient = await new ApiClient().create(); + }); + + test.afterAll(async () => { + await apiClient.dispose(); + }); + + /** + * Test: User changes their password + * @param page - Playwright page instance used to interact with the website. + * @param request - APIRequestContext instance used to create accounts with the API. + */ + test('Change_password', { tag: ['@account-credentials', '@hot'] }, async ({ page, request }) => { + const accountPage = new AccountPage(page); + const loginPage = new LoginPage(page); + + const parallelIndex = test.info().parallelIndex; + const email = `playwright_pwtest_${parallelIndex}@elgentos.nl`; + const password = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + const changedPassword = requireEnv('MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD'); + + // Ensure a fresh account exists with the original password + const searchResponse = await apiClient.get( + `/rest/V1/customers/search` + + `?searchCriteria[filterGroups][0][filters][0][field]=email` + + `&searchCriteria[filterGroups][0][filters][0][value]=${email}` + + `&searchCriteria[filterGroups][0][filters][0][conditionType]=eq` + ); + + if (searchResponse.items?.length > 0) { + await apiClient.delete(`/rest/V1/customers/${searchResponse.items[0].id}`); + } + + await apiClient.post('/rest/V1/customers', { + customer: { + email, + firstname: inputValues.account.firstName, + lastname: inputValues.account.lastName, + }, + password, + }); + + // Login and change password via UI + await loginPage.login(email, password); + await page.goto(slugs.account.changePasswordSlug, { waitUntil: 'load' }); + await expect(page.getByRole('textbox', { name: UIReference.credentials.currentPasswordFieldLabel })).toBeVisible(); + await accountPage.updatePassword(password, changedPassword); + + // Verify the new password works via API + const tokenResponse = await request.post('/rest/V1/integration/customer/token', { + data: { username: email, password: changedPassword }, + }); + expect(tokenResponse.ok(), 'Customer token API should accept the new password').toBeTruthy(); + }); + + /** + * Test: User changes their e-mailaddress + * @param page - Playwright page instance used to interact with the website. + * @param request - APIRequestContext instance used to create accounts with the API. + */ + test('Update_email_address', { tag: ['@account-credentials', '@hot'] }, async ({ page, request }) => { + const accountPage = new AccountPage(page); + const loginPage = new LoginPage(page); + + const parallelIndex = test.info().parallelIndex; + const originalEmail = `playwright_emailtest_${parallelIndex}@elgentos.nl`; + const updatedEmail = `playwright_emailtest_updated_${parallelIndex}@elgentos.nl`; + const password = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + // Ensure a fresh account exists with the original email + const searchResponse = await apiClient.get( + `/rest/V1/customers/search` + + `?searchCriteria[filterGroups][0][filters][0][field]=email` + + `&searchCriteria[filterGroups][0][filters][0][value]=${originalEmail}` + + `&searchCriteria[filterGroups][0][filters][0][conditionType]=eq` + ); + + if (searchResponse.items?.length > 0) { + await apiClient.delete(`/rest/V1/customers/${searchResponse.items[0].id}`); + } + + // Also clean up any leftover updated email account from a previous run + const updatedSearchResponse = await apiClient.get( + `/rest/V1/customers/search` + + `?searchCriteria[filterGroups][0][filters][0][field]=email` + + `&searchCriteria[filterGroups][0][filters][0][value]=${updatedEmail}` + + `&searchCriteria[filterGroups][0][filters][0][conditionType]=eq` + ); + + if (updatedSearchResponse.items?.length > 0) { + await apiClient.delete(`/rest/V1/customers/${updatedSearchResponse.items[0].id}`); + } + + await apiClient.post('/rest/V1/customers', { + customer: { + email: originalEmail, + firstname: inputValues.account.firstName, + lastname: inputValues.account.lastName, + }, + password, + }); + + // Login and update email via UI + await loginPage.login(originalEmail, password); + await page.goto(slugs.account.accountEditSlug, { waitUntil: 'load' }); + await expect(page.locator('#form-validate'). + getByText(UIReference.accountDashboard.accountDashboardTitleLabel), + `Heading "${UIReference.accountDashboard.accountDashboardTitleLabel}" is visible`).toBeVisible(); + await accountPage.updateEmail(password, updatedEmail); + + // Verify the updated email works via API + const tokenResponse = await request.post('/rest/V1/integration/customer/token', { + data: { username: updatedEmail, password: password }, + }); + expect(tokenResponse.ok(), 'Customer token API should accept the updated email').toBeTruthy(); + }); +}); + +/** + * Test Group: Account address book actions + */ +test.describe.serial('Account address book actions', { annotation: {type: 'Account Dashboard', description: 'Tests for the Address Book'},}, () => { + + test.beforeEach(async ({page}) => { + await page.goto(slugs.account.addressIndexSlug, {waitUntil: "load"}); + + // if page navigated to new address, no address had been added yet. + if(page.url().includes('new')){ + await expect(async () => { + await expect(page.getByText(UIReference.newAddress.addNewAddressTitle), + `Heading "${UIReference.newAddress.addNewAddressTitle}" is visible`).toBeVisible(); + }).toPass(); + } else { + await expect(async () => { + await expect(page.getByRole('heading', + { name: UIReference.address.addressBookTitle }), + `Heading "${UIReference.address.addressBookTitle}" is visible`).toBeVisible(); + }).toPass(); + } + }); + + /** + * Test: The user adds an address to their account + * @assume the user is already logged in. + * @param page - Playwright page instance used to interact with the website. + */ + test('Add_an_address',{ tag: ['@address-actions', '@hot'] }, async ({page}) => { + await page.goto(slugs.account.addressNewSlug); + const accountPage = new AccountPage(page); + + const address = `${faker.location.streetAddress()} ${Math.floor(Math.random() * 100 + 1)}`; + const company = faker.company.name(); + + await accountPage.addNewAddress({ company: company, street: address}); + + await expect(page.getByText(address).first(), `Expect new address to be listed`).toBeVisible(); + await expect(page.getByText(company).first(), `Expect new company name to be listed`).toBeVisible(); + }); + + /** + * Test: The user edits an existing address to their account + * @assume the user is already logged in. + * @param page - Playwright page instance used to interact with the website. + */ + test('Edit_existing_address',{ tag: ['@address-actions', '@hot'] }, async ({page}) => { + const accountPage = new AccountPage(page); + await page.goto(slugs.account.addressBookSlug); + let editAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.editAddressIconButton}).first(); + let isDefaultAddress = false; + + if(await editAddressButton.isHidden()){ + // The edit address button was not found, add another address first. + if(await page.getByRole('link', { name: 'Change Shipping Address arrow' }).isVisible()) { + isDefaultAddress = true; + } else { + expect (page.url(), `Edit address button not found, check URL is to the new address page`).toBe(slugs.account.addressNewSlug); + await accountPage.addNewAddress(); + } + } + + // const companyName = faker.company.name(); + const address = `${faker.location.streetAddress()} ${Math.floor(Math.random() * 100 + 1)}`; + await accountPage.editExistingAddress({street:address}, isDefaultAddress); + + // await expect(page.getByText(companyName)).toBeVisible(); + await expect(page.getByText(address).first()).toBeVisible(); + }); + + /** + * Test: The user can't add an address if they don't fill in all the required fields + * @assume the user is already logged in. + * @param page - Playwright page instance used to interact with the website. + */ + test('Missing_required_field_prevents_creation',{ tag: ['@address-actions'] }, async ({page}) => { + await page.goto(slugs.account.addressNewSlug); + const accountPage = new AccountPage(page); + + await accountPage.phoneNumberField.fill(inputValues.firstAddress.firstPhoneNumberValue); + await accountPage.saveAddressButton.click(); + + const errorMessage = page.getByText(UIReference.general.errorMessageStreetAddressRequiredFieldText).first(); + await errorMessage.waitFor(); + await expect(errorMessage, `Error message "${UIReference.general.errorMessageStreetAddressRequiredFieldText}" is visible`).toBeVisible(); + }); +}); + +/** + * Test Group: Newsletter tests + */ +test.describe('Newsletter actions', { annotation: {type: 'Account Dashboard', description: 'Newsletter tests'},}, () => { + + /** + * Test: The user (un)subscribes from the newsletter + * @assume the user is already logged in. + * @param page - Playwright page instance used to interact with the website. + */ + test('Update_newsletter_subscription',{ tag: ['@newsletter-actions', '@cold'] }, async ({page}) => { + // Navigate to a page. + await page.goto(slugs.account.accountOverviewSlug); + await page.waitForLoadState(); + + const newsletterPage = new NewsletterSubscriptionPage(page); + let newsletterLink = page.getByRole('link', { name: UIReference.accountDashboard.links.newsletterLink }); + const newsletterCheckElement = page.getByLabel(UIReference.newsletterSubscriptions.generalSubscriptionCheckLabel); + + await newsletterLink.click(); + await expect(page.getByText(outcomeMarker.account.newsletterSubscriptionTitle, { exact: true })).toBeVisible(); + + let updateSubscription = await newsletterPage.updateNewsletterSubscription(); + + await newsletterLink.click(); + + if(updateSubscription) { + await expect(newsletterCheckElement).toBeChecked(); + } + else { + await expect(newsletterCheckElement).not.toBeChecked(); + } + }); +}); diff --git a/dev/tests/e2e/base-tests/auth.setup.ts b/dev/tests/e2e/base-tests/auth.setup.ts new file mode 100644 index 00000000000..8a88818af62 --- /dev/null +++ b/dev/tests/e2e/base-tests/auth.setup.ts @@ -0,0 +1,31 @@ +// @ts-check + +import { test as setup, expect } from '@playwright/test'; +import path from 'path'; +import { UIReference, slugs } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +const authFile = path.join(__dirname, '../playwright/.auth/user.json'); + +setup('authenticate', async ({ page, browserName }) => { + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + // Perform authentication steps. Replace these actions with your own. + await page.goto(slugs.account.loginSlug); + await page.getByLabel(UIReference.credentials.emailFieldLabel, {exact: true}).fill(emailInputValue); + await page.getByLabel(UIReference.credentials.passwordFieldLabel, {exact: true}).fill(passwordInputValue); + await page.getByRole('button', { name: UIReference.credentials.loginButtonLabel }).click(); + // Wait until the page receives the cookies. + // + // Sometimes login flow sets cookies in the process of several redirects. + // Wait for the final URL to ensure that the cookies are actually set. + // await page.waitForURL(''); + // Alternatively, you can wait until the page reaches a state where all cookies are set. + await expect(page.getByRole('link', { name: UIReference.mainMenu.myAccountLogoutItem })).toBeVisible(); + + // End of authentication steps. + + await page.context().storageState({ path: authFile }); +}); diff --git a/dev/tests/e2e/base-tests/category.spec.ts b/dev/tests/e2e/base-tests/category.spec.ts new file mode 100644 index 00000000000..bfd07bf6393 --- /dev/null +++ b/dev/tests/e2e/base-tests/category.spec.ts @@ -0,0 +1,66 @@ +// @ts-check + +import { test } from '@playwright/test'; + +import CategoryPage from '@poms/frontend/category.page'; + +/** + * @feature Filter category page + * @scenario User filters category page on size L + * @given I navigate to the category page + * @when I open the Size filter category + * @and I click the size L button + * @then the URL should reflect this filter + * @and I should see fewer products + */ +test('Filter_category_on_size',{ tag: ['@category', '@cold']}, async ({page}) => { + const categoryPage = new CategoryPage(page); + await categoryPage.goToCategoryPage(); + + await categoryPage.filterOnSize(); +}); + +/** + * @feature Sort category page by price + * @scenario User sorts category page by price + * @given I navigate to the category page + * @when I open the 'Sort' dropdown + * @and I click the price button + * @then the URL should reflect this filter + * @and I should see products sorted by price + */ +test('Sort_category_by_price',{ tag: ['@category', '@cold']}, async ({page}) => { + const categoryPage = new CategoryPage(page); + await categoryPage.goToCategoryPage(); + + await categoryPage.sortProducts('price'); +}); + +/** + * @feature products per page + * @scenario User updates the amount of products shown on the page + * @given I navigate to the category page + * @when I change the 'Show' dropdown + * @then the URl should reflect this filter + * @and the amount of items should be the new amount I've selected + */ +test('Change_amount_of_products_shown',{ tag: ['@category', '@cold'],}, async ({page}) => { + const categoryPage = new CategoryPage(page); + await categoryPage.goToCategoryPage(); + + await categoryPage.showMoreProducts(); +}); + +/** + * @feature View switcher + * @scenario User switches from the grid to the list view + * @given I navigate to the category page + * @when I click the grid or list mode button + * @then the URl should reflect this updated view + * @and the reported selected view should not be the same as it was before I clicked the button + */ +test('Switch_from_grid_to_list_view',{ tag: ['@category', '@cold'],}, async ({page}) => { + const categoryPage = new CategoryPage(page); + await categoryPage.goToCategoryPage(); + await categoryPage.switchView(); +}); diff --git a/dev/tests/e2e/base-tests/checkout.spec.ts b/dev/tests/e2e/base-tests/checkout.spec.ts new file mode 100644 index 00000000000..53e3b7ff096 --- /dev/null +++ b/dev/tests/e2e/base-tests/checkout.spec.ts @@ -0,0 +1,221 @@ +// @ts-check + +// Import test and expect from utils to ensure authenticated state. +import { test, expect } from '@utils/fixtures.utils'; +import { UIReference, slugs } from '@config'; +import { requireEnv } from '@utils/env.utils'; +import MagewireUtils from '@utils/magewire.utils'; + +import LoginPage from '@poms/frontend/login.page'; +import ProductPage from '@poms/frontend/product.page'; +import AccountPage from '@poms/frontend/account.page'; +import MainMenuPage from '@poms/frontend/mainmenu.page'; +import CheckoutPage from '@poms/frontend/checkout.page'; + +test.describe('Checkout (login required)', () => { + /** + * @feature BeforeEach runs before each test in this group. + * @scenario Add product to the cart, confirm it's there, then move to checkout. + * @given I am on a page + * @when I navigate to a (simple) product page + * @and I add it to my cart + * @then I should see a notification + * @when I navigate to the checkout + * @then the checkout page should be shown + * @and I should see the product in the minicart + */ + test.beforeEach(async ({ page }) => { + const magewire = new MagewireUtils(page); + magewire.startMonitoring(); + + const productPage = new ProductPage(page); + + await page.goto(slugs.productPage.simpleProductSlug); + await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + await page.goto(slugs.checkout.checkoutSlug); + }); + + // Before each test, go to checkout + test.beforeEach(async ({ page }) => { + await page.goto(slugs.checkout.checkoutSlug); + }); + + /** + * @feature Automatically fill in certain data in checkout (if user is logged in) + * @scenario When the user navigates to the checkout (with a product), their name and address should be filled in. + * @given I am logged in + * @and I have a product in my cart + * @and I have navigated to the checkout page + * @then My name and address should already be filled in + */ + test('Address_is_pre_filled_in_checkout',{ tag: ['@checkout', '@hot']}, async ({page}) => { + let signInLink = page.getByRole('link', { name: UIReference.credentials.loginButtonLabel }); + let addressField = page.getByLabel(UIReference.newAddress.streetAddressLabel); + let addressAlreadyAdded = false; + + if(await signInLink.isVisible()) { + throw new Error(`Sign in link found, user is not logged in. Please check the test setup.`); + } + + // name field should NOT be on the page + await expect(page.getByLabel(UIReference.personalInformation.firstNameLabel)).toBeHidden(); + + if(await addressField.isVisible()) { + if(!addressAlreadyAdded){ + // Address field is visible and addressalreadyAdded is not true, so we need to add an address to the account. + const accountPage = new AccountPage(page); + await accountPage.addNewAddress(); + } else { + throw new Error(`Address field is visible even though an address has been added to the account.`); + } + } + + // expect to see radio button to select existing address + let shippingRadioButton = page.locator(UIReference.checkout.shippingAddressRadioLocator).first(); + await expect(shippingRadioButton, 'Radio button to select address should be visible').toBeVisible(); + }); + + /** + * @feature Place order for simple product + * @scenario User places an order for a simple product + * @given I have a product in my cart + * @and I am on any page + * @when I navigate to the checkout + * @and I fill in the required fields + * @and I click the button to place my order + * @then I should see a confirmation that my order has been placed + * @and a order number should be created and show to me + */ + test('Place_order_for_simple_product',{ tag: ['@simple-product-order', '@hot'],}, async ({page}, testInfo) => { + const checkoutPage = new CheckoutPage(page); + let orderNumber = await checkoutPage.placeOrder(); + testInfo.annotations.push({ type: 'Order number', description: `${orderNumber}` }); + }); +}); + +test.describe('Checkout (guest)', () => { + test.beforeEach(async({page}) => { + // log out + const mainMenu = new MainMenuPage(page); + await mainMenu.logout(); + + // set up magewire monitoring + const magewire = new MagewireUtils(page); + magewire.startMonitoring(); + + // ensure product in cart + const productPage = new ProductPage(page); + await page.goto(slugs.productPage.simpleProductSlug); + await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + + // to checkout + await page.goto(slugs.checkout.checkoutSlug); + }); + + /** + * @feature Discount Code + * @scenario User adds a discount code to their cart + * @given I have a product in my cart + * @and I am on my cart page + * @when I click on the 'add discount code' button + * @then I fill in a code + * @and I click on 'apply code' + * @then I should see a confirmation that my code has been added + * @and the code should be visible in the cart + * @and a discount should be applied to the product + */ + test('Add_coupon_code_in_checkout',{ tag: ['@checkout', '@coupon-code', '@cold']}, async ({page, browserName}) => { + const checkout = new CheckoutPage(page); + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const discountCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); + + await checkout.applyDiscountCodeCheckout(discountCode); + }); + + test('Verify_price_calculations_in_checkout', { tag: ['@checkout', '@price-calculation'] }, async ({ page }) => { + const productPage = new ProductPage(page); + const checkoutPage = new CheckoutPage(page); + + // Add product to cart and go to checkout + await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + await page.goto(slugs.checkout.checkoutSlug); + + // Select shipping method to trigger price calculations + await checkoutPage.shippingMethodOptionFixed.check(); + + // Wait for totals to update + await expect(async () => { + await page.locator('.magewire\\.messenger').waitFor({state: "hidden"}); + }).toPass(); + + // // Wait for totals to update + // await page.waitForFunction(() => { + // const element = document.querySelector('.magewire\\.messenger'); + // return element && getComputedStyle(element).height === '0px'; + // }); + + // Get all price components using the verifyPriceCalculations method from the CheckoutPage fixture + await checkoutPage.verifyPriceCalculations(); + }); + + /** + * @feature Remove discount code from checkout + * @scenario User has added a discount code, then removes it + * @given I have a product in my cart + * @and I am on the checkout page + * @when I add a discount code + * @then I should see a notification + * @and the code should be visible in the cart + * @and a discount should be applied to a product + * @when I click the 'cancel coupon' button + * @then I should see a notification the discount has been removed + * @and the discount should no longer be visible. + */ + test('Remove_coupon_code_from_checkout',{ tag: ['@checkout', '@coupon-code', '@cold']}, async ({page, browserName}) => { + const checkout = new CheckoutPage(page); + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const discountCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); + + await checkout.applyDiscountCodeCheckout(discountCode); + await checkout.removeDiscountCode(); + }); + + /** + * @feature Incorrect discount code check + * @scenario The user provides an incorrect discount code, the system should reflect that + * @given I have a product in my cart + * @and I am on the cart page + * @when I enter a wrong discount code + * @then I should get a notification that the code did not work. + */ + test('Invalid_coupon_code_in_checkout_is_rejected',{ tag: ['@checkout', '@coupon-code', '@cold'] }, async ({page}) => { + const checkout = new CheckoutPage(page); + await checkout.enterWrongCouponCode("incorrect discount code"); + }); + + /** + * @feature Payment Method Selection + * @scenario Guest user selects different payment methods during checkout + * @given I have a product in my cart + * @and I am on the checkout page as a guest + * @when I select a payment method + * @and I complete the checkout process + * @then I should see a confirmation that my order has been placed + * @and a order number should be created and shown to me + */ + test('Guest_can_select_payment_methods', { tag: ['@checkout', '@payment-methods', '@cold'] }, async ({ page }) => { + // Marking test as slow to allow more time befoure timeout + test.slow(); + const checkoutPage = new CheckoutPage(page); + + // Test with check/money order payment + await test.step('Place order with check/money order payment', async () => { + await page.goto(slugs.checkout.checkoutSlug); + await checkoutPage.fillShippingAddress(); + await checkoutPage.selectShippingMethod('fixed'); + await checkoutPage.selectPaymentMethod('check'); + let orderNumber = await checkoutPage.placeOrder(); + expect(orderNumber, 'Order number should be generated and returned').toBeTruthy(); + }); + }); +}); diff --git a/dev/tests/e2e/base-tests/config/element-identifiers.json b/dev/tests/e2e/base-tests/config/element-identifiers.json new file mode 100644 index 00000000000..9a518441732 --- /dev/null +++ b/dev/tests/e2e/base-tests/config/element-identifiers.json @@ -0,0 +1,370 @@ +{ + "account" : { + "addressBookHeading": "Address Book", + "dashboardHeading" : "Account Information", + "editContactInfo": "Edit contact information", + "newAddressHeading" : "Add New Address" + }, + "accountCreation": { + "createAccountButtonLabel": "Create an Account", + "createAccountTitleText" : "Create New Customer Account" + }, + "accountDashboard": { + "accountDashboardTitleLabel": "Account Information", + "accountSideBarLabel": "Sidebar Main", + "addAddressButtonLabel": "ADD NEW ADDRESS", + "addressBookArea": ".block-addresses-list", + "addressDeleteIconButton": "Delete", + "editAddressIconButton": "pencil-alt", + "accountInformationFieldLocator": ".column > div > div > .flex", + "links": { + "newsletterLink": "Newsletter Subscriptions" + } + }, + "address" : { + "addressField" : "Street Address", + "addressSavedText": "You saved the address.", + "cityField" : "City", + "companyField" : "Company", + "countryField" : "Country", + "phoneField" : "Phone Number", + "regionInputField": "State/Province", + "regionLocator": "#region_id", + "saveAddressButton": "Save Address", + "zipCodeField" : "Zip/Postal Code", + "TO_DEPRECATE": "addressBookTitle Below - moved to account", + "addressBookTitle" : "Address Book" + }, + "admin" : { + "admin": "Admin", + "adminSharing": "Admin Account Sharing", + "advanced": "Advanced", + "captchaEnabled": "Enable CAPTCHA on Storefront", + "configuration": "Configuration", + "configTabLocator" : "#system_config_tabs", + "configurationSavedText": "You saved the configuration.", + "customerCAPTCHAInheritLocator": "#customer_captcha_enable_inherit", + "customerInheritLocator": "#admin_security_admin_account_sharing_inherit", + "customers": "Customers", + "customerConfiguration": "Customer Configuration", + "recordsFoundLocator": "#promo_quote_grid-total-count", + "storesButton" : "Stores" + }, + "adminMarketing" : { + "cartPriceRules": "Cart Price Rules", + "couponSearchLocator": "#promo_quote_grid_filter_coupon_code", + "marketingButton" : "Marketing" + }, + "authentication" : { + "adminUsernameFieldId": "#username", + "adminPasswordFieldId": "#login", + "adminLoginButtonClass": ".action-login", + "adminLoginText": "Welcome, please sign in", + "changePassword": "Change Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "newPasswordConfirm": "Confirm New Password" + }, + "commerce" : { + "updateCartButton": "Update Shopping Cart" + }, + "errors" : { + "captchaIncorrect" : "Incorrect CAPTCHA." + }, + "adminGeneral": { + "tableSearchFieldLabel": "Search by keyword", + "tableFilterResetLabel": "Clear All", + "loadingSpinnerLocator": "#container .spinner", + "searchButtonLabel": "Search" + }, + "adminCustomers": { + "createNewCustomerButtonLabel": "Add New Customer", + "edit": { + "passwordFieldLabel": "Enter Password" + }, + "registration": { + "debtorNumberFieldLabel": "Debtor number", + "allowBulkPurchaseFieldLabel": "Allow bulk purchase", + "createAccountSaveButtonLabel": "Save Customer", + "createAccountSaveAndContinueButtonLabel": "Save and Continue Edit" + } + }, + "adminPage": { + "captchaIncorrectText" : "Incorrect CAPTCHA.", + "captchaDisabledLabel": "No", + "couponSearchFieldLocator" : "#promo_quote_grid_filter_coupon_code", + "dashboardHeadingText": "Dashboard", + "navigation": { + "customersButtonLabel": "Customers", + "marketingButtonLabel": "Marketing", + "storesButtonLabel": "Stores", + "salesButtonLabel": "Sales" + }, + "searchResultsText" : "records found", + "subNavigation": { + "allCustomersButtonLabel": "All Customers", + "cartPriceRulesButtonLabel": "Cart Price Rules", + "configurationButtonLabel": "Configuration", + "ordersButtonLabel": "Orders" + }, + "usernameFieldLabel": "Username", + "passwordFieldLabel": "Password", + "loginButtonLabel": "Sign In", + "usernameFieldId": "#username", + "passwordFieldId": "#login", + "loginButtonClass": ".action-login" + }, + "newAddress": { + "addNewAddressTitle": "Add New Address", + "cityNameLabel": "City", + "companyNameLabel": "Company", + "countryLabel": "Country", + "phoneNumberLabel": "Phone Number", + "provinceSelectLabel": "State/Province", + "provinceSelectFilterLabel": "Please select a region, state or province.", + "regionDropdownLocator": "#region_id", + "streetAddressLabel": "Street Address", + "saveAdressButton": "Save Address", + "zipCodeLabel": "Zip/Postal Code" + }, + "newsletterSubscriptions": { + "generalSubscriptionCheckLabel": "General Subscription", + "saveSubscriptionsButton": "Save" + }, + "productPage": { + "addToCartButtonLocator": "Add to Cart", + "addToCompareButtonLabel": "Add to Compare", + "addToWishlistButtonLabel": "Add to Wish List", + "simpleProductTitle": "Push It Messenger Bag", + "secondSimpleProducTitle": "Aim Analog Watch", + "simpleProductPrice": ".final-price .price-wrapper .price", + "configurableProductTitle": "Inez Full Zip Jacket", + "configurableProductSizeLabel": "Size", + "configurableProductColorLabel": "Color", + "configurableProductOptionForm": "#product_addtocart_form", + "configurableProductOptionValue": ".product-option-value-label", + "quantityFieldLabel": "Quantity", + "fullScreenOpenLabel": "Click to view image in", + "fullScreenCloseLabel": "Close fullscreen", + "thumbnailImageLabel": "View larger image", + "reviewCountLabel": "Show items per page" + }, + "categoryPage":{ + "activeViewLocator": ".active", + "categoryPageTitleText": "Women", + "firstFilterOptionLocator": "#filter-option-0-content", + "itemsOnPageAmountLocator": ".toolbar-number", + "itemsPerPageButtonLabel": "Show items per page", + "productGridLocator": ".products-grid", + "removeActiveFilterButtonLabel": "Remove active", + "sizeFilterButtonLabel": "Size filter", + "sizeXSLinkLabel": "XS", + "activeFilterButtonLabel": "Active filtering", + "clearAllFiltersLinkLabel": "Clear All", + "sortByButtonLabel": "Sort by", + "sortByButtonLocator": ".form-select.sorter-options", + "viewSwitchLabel": "Products view mode", + "viewGridLabel": "Products view mode - Grid", + "viewListLabel": "Products view mode - List" + }, + "cart": { + "applyDiscountButtonLabel": "Apply Discount", + "cancelCouponButtonLabel": "Cancel Coupon", + "cartTitleText": "Shopping Cart", + "cartQuantityLabel": "Qty", + "discountInputFieldLabel": "Enter discount code", + "showDiscountFormButtonLabel": "Apply Discount Code", + "updateItemButtonLabel": "Update item", + "updateShoppingCartButtonLabel": "Update Shopping Cart" + }, + "cartPriceRulesPage": { + "actionsSubtitleLabel": "Actions", + "activeStatusLabelLocator": ".admin__actions-switch-text", + "activeStatusSwitcherLocator": ".admin__actions-switch-label", + "addCartPriceRuleButtonLabel": "Add New Rule", + "clearSearchButtonLabel" : "Reset Filter", + "couponCodeFieldLabel": "Coupon Code", + "couponCodeActiveStatusText": "Active", + "couponTypeSelectField": "select[name='coupon_type']", + "customerGroupsSelectLabel": "Customer Groups", + "discountAmountFieldLabel": "Discount Amount", + "ruleNameFieldLabel": "Rule Name", + "saveRuleButtonLabel": "Save", + "websitesSelectLabel": "Websites" + }, + "checkout": { + "applyDiscountButtonLabel": "Apply Coupon", + "applyDiscountCodeLabel": "Apply Discount Code", + "cancelDiscountButtonLabel": "Cancel Coupon", + "cartDetailsLocator": "#checkout-cart-details div", + "continueShoppingLabel": "Continue Shopping", + "discountInputFieldLabel": "Enter discount code", + "openCartButtonLabel": "Cart", + "openCartButtonLabelCont": "item", + "openCartButtonLabelContMultiple": "items", + "openCartDetailsButtonLocator": "button[aria-controls=\"checkout-cart-details\"]", + "openDiscountFormLabel": "Apply Discount Code", + "paymentOptionCheckLabel": "Check / Money order", + "paymentOptionCreditCardLabel": "Credit Card", + "paymentOptionPaypalLabel": "PayPal", + "checkoutCartDetailsLocator": "#checkout-cart-details", + "creditCardNumberLabel": "Credit Card Number", + "creditCardExpiryLabel": "Expiration Date", + "creditCardCVVLabel": "Card Verification Number", + "creditCardNameLabel": "Name on Card", + "placeOrderButtonLabel": "Place Order", + "remove": "Remove", + "shippingAddressRadioLocator": "#shipping-details input[type='radio']", + "shippingMethodFixedLabel": "Fixed", + "shippingMethodTableRateLabel": "Table Rate", + "shippingPriceText": "Shipping & Handling (Flat Rate - Fixed)", + "taxPriceText": "Tax" + }, + "comparePage": { + "removeCompareLabel": "Remove Product", + "addToWishListLabel": "Add to Wish List", + "comparisonPageEmptyText": "You have no items to compare.", + "comparisonPageTitleText": "Compare Products" + }, + "configurationPage": { + "advancedAdministrationTabLabel": "Admin", + "advancedTabLabel": "Advanced", + "allowMultipleLoginsSelectField": "#admin_security_admin_account_sharing", + "allowMultipleLoginsSystemCheckbox": "#admin_security_admin_account_sharing_inherit", + "captchaSettingSelectField": "#customer_captcha_enable", + "captchaSettingSystemCheckbox": "#customer_captcha_enable_inherit", + "captchaSectionLabel": "CAPTCHA", + "customerConfigurationTabLabel": "Customer Configuration", + "customersTabLabel": "Customers", + "saveConfigButtonLabel": "Save Config", + "securitySectionLabel": "Security" + }, + "contactPage": { + "messageFieldSelector": "#comment" + }, + "credentials": { + "currentPasswordFieldLabel": "Current Password", + "emailFieldLabel": "Email", + "emailCheckoutFieldLabel": "Email address", + "loginButtonLabel": "Sign In", + "nameFieldLabel": "Name", + "newPasswordConfirmFieldLabel": "Confirm New Password", + "newPasswordFieldLabel": "New Password", + "passwordConfirmFieldLabel": "Confirm Password", + "passwordFieldLabel": "Password" + }, + "customerOverviewPage": { + "tableSearchFieldLabel": "Search by keyword" + }, + "financial" : { + "subTotal": "Subtotal", + "grandTotal": "Grand Total" + }, + "footerPage": { + "footerLocator": ".page-footer", + "currencyIdentifier": "#currency-heading", + "currencyLabel": "Currency", + "currencyDollar": "USD - US Dollar", + "currencyEuro": "EUR - Euro", + "newsletterInputElementLabel": "Email Address", + "newsletterSubscribeButtonLabel": "Subscribe" + }, + "general": { + "adminMessageLocator": "#messages .message div", + "firstNameField": "First Name", + "general": "General", + "headingOneLocator": "h1.page-title", + "lastNameField": "Last Name", + "messageLocator" : "#messages div div.messages div div.message span", + "priceSymbol": "$", + "save": "Save", + "saveConfigButton": "Save Config", + "security" : "Security", + "search": "Search", + "quantityAbbr": "Qty", + "TO_BE_DEPRECATED_BELOW": "Try not to use", + "addToCartLabel": "Add to Cart", + "closeMessageLabel": "Close message", + "errorMessageLocator": "#messages div .messages div .error span", + "errorMessageStreetAddressRequiredFieldText": "Street Address field is required.", + "genericPriceLabel": "Price", + "genericPriceSymbol": "$", + "genericSaveButtonLabel": "Save", + "genericSubmitButtonLabel": "Submit", + "headerLocator": "#header", + "loadingSpinnerLocator": "#container .spinner", + "removeLabel": "Remove", + "searchButtonLabel": "Search", + "successMessageLocator": "div.message.success" + }, + "homePage": { + "homePageTitleText": "Hyvä Theme" + }, + "menu" : { + "logoutLink" : "Sign Out" + }, + "mainMenu": { + "addressBookButtonLabel" : "Address Book", + "createAccountButtonLabel" : "Create an Account", + "subCategoryItemText" : "Fitness Equipment", + "categoryItemText" : "Gear", + "loginButtonLabel" : "Sign In", + "miniCartLabel": "Toggle minicart", + "myAccountButtonLabel": "My Account", + "myAccountLogoutItem": "Sign Out", + "myOrdersButtonLabel" : "My Orders", + "searchButtonLabel" : "Toggle search form", + "wishListButtonLabel" : "My Wish List" + }, + "miniCart": { + "cartDrawerLocator": "#cart-drawer-title", + "cartEmptyText": "Cart is empty", + "checkOutButtonLabel": "Checkout", + "editProductIconLabel": "Edit product", + "minicartButtonLocator": "#menu-cart-icon", + "minicartAmountBubbleLocator": "#menu-cart-icon > span", + "minicartPriceFieldClass": ".price-excluding-tax .minicart-price .price", + "miniCartToggleLabelEmpty": "Cart is empty", + "miniCartToggleLabelMultiItem": "items", + "miniCartToggleLabelOneItem": "1 item", + "miniCartToggleLabelPrefix": "Toggle minicart,", + "productQuantityFieldLabel": "Quantity", + "removeProductIconLabel": "Remove product", + "toCartLinkLabel": "View and Edit Cart" + }, + "search": { + "searchBoxPlaceholderText" : "Search entire store here...", + "searchToggleLocator": "#menu-search-icon", + "searchInputLocator": "#search", + "suggestionBoxLocator": "#search_autocomplete", + "searchResultsTitle": "Search results for:", + "searchTermDropdownText" : "Search terms" + }, + "titles" : { + "accountHeading" : "My Account", + "adminDashboardHeading": "Dashboard", + "categoryHeading": "Women", + "cartHeading": "Shopping Cart", + "homeHeading" : "Hyvä Theme", + "loginHeading": "Customer Login", + "signedOutHeading": "You have signed out", + "simpleProductHeading": "Push It Messenger Bag" + }, + "toasts" : { + "accountInfoSaved" : "You saved the account information." + }, + "orderHistoryPage" : { + "orderHistoryTitle": "My Orders" + }, + "personalInformation": { + "changePasswordSwitchLabel": "Change Password", + "changeEmailCheckLabel": "Change Email", + "firstNameLabel": "First Name", + "lastNameLabel": "Last Name" + }, + "wishListPage": { + "wishListItemGridLabel": "#wishlist-view-form", + "wishListTitle" : "My Wish List", + "updateCompareListButtonLabel": "Update Wish List" + } +} diff --git a/dev/tests/e2e/base-tests/config/index.ts b/dev/tests/e2e/base-tests/config/index.ts new file mode 100644 index 00000000000..e76016fc555 --- /dev/null +++ b/dev/tests/e2e/base-tests/config/index.ts @@ -0,0 +1,39 @@ +// @ts-check + +import fs from 'fs'; +import path from 'path'; + +function deepMerge(target: any, source: any): any { + for (const key in source) { + if (source[key] instanceof Object && key in target) { + Object.assign(source[key], deepMerge(target[key], source[key])); + } + } + // Combine the two objects + return { ...target, ...source }; +} + +function loadAndMergeConfig(fileName: string) { + const fallbackPath = path.resolve(__dirname, fileName); + const currentPath = path.resolve(__dirname, '../../tests/config/', fileName); + + let currentConfig = {}; + let fallbackConfig = {}; + + if (fs.existsSync(currentPath)) { + currentConfig = JSON.parse(fs.readFileSync(currentPath, 'utf-8')); + } + + if (fs.existsSync(fallbackPath)) { + fallbackConfig = JSON.parse(fs.readFileSync(fallbackPath, 'utf-8')); + } + + // Use deepMerge instead of shallow merge + return deepMerge(fallbackConfig, currentConfig); +} + +export const UIReference = loadAndMergeConfig('element-identifiers.json'); +export const outcomeMarker = loadAndMergeConfig('outcome-markers.json'); +export const inputValues = loadAndMergeConfig('input-values.json'); +export const slugs = loadAndMergeConfig('slugs.json'); +export const toggles = loadAndMergeConfig('test-toggles.json'); \ No newline at end of file diff --git a/dev/tests/e2e/base-tests/config/input-values.json b/dev/tests/e2e/base-tests/config/input-values.json new file mode 100644 index 00000000000..623a08b1ec9 --- /dev/null +++ b/dev/tests/e2e/base-tests/config/input-values.json @@ -0,0 +1,71 @@ +{ + "account" : { + "firstName": "Playwright", + "lastName": "TestAccount" + }, + "accountCreation": { + "emailHandleValue":"test-user", + "emailHostValue": "gmail.com", + "firstNameValue": "John", + "lastNameValue": "Doe" + }, + "adminLogins": { + "allowMultipleLogins": "Yes" + }, + "captcha": { + "captchaDisabled": "No" + }, + "contact": { + "contactFormEmailValue": "robertbaratheon@gameofthrones.com", + "contactFormMessage": "Hello! I am filling out this form as a test only. Feel free to ignore this message." + }, + "coupon": { + "couponCodeRuleName": "Test coupon", + "couponType": "Specific Coupon" + }, + "addressCountries": [ + "Netherlands", + "United Kingdom", + "United States" + ], + "admin" : { + "simpleProductSKU" : "24-WB04" + }, + "editedAddress": { + "editCityValue": "Pallet Town", + "editfirstNameValue": "Ash", + "editLastNameValue": "Ketchum", + "editStateValue": "Kansas", + "editStreetAddressValue": "House on the left", + "editZipCodeValue": "00151" + }, + "firstAddress": { + "firstCityValue": "Testing Valley", + "firstNonDefaultCountry": "Netherlands", + "firstPhoneNumberValue": "0622000000", + "firstProvinceValue": "Idaho", + "firstStreetAddressValue": "Testingstreet 1", + "firstZipCodeValue": "12345" + }, + "payment": { + "creditCard": { + "number": "4111111111111111", + "expiry": "12/25", + "cvv": "123", + "name": "Test User" + } + }, + "secondAddress": { + "secondCityValue": "Little Whinging", + "secondNonDefaultCountry": "United Kingdom", + "secondPhoneNumberValue": "0620081998", + "secondProvinceValue": "South Dakota", + "secondStreetAddressValue": "Under the Stairs", + "secondZipCodeValue": "67890" + }, + "search": { + "queryMultipleResults": "bag", + "querySpecificProduct": "Push It Messenger Bag", + "queryNoResults": "sdfasdfasddd" + } +} diff --git a/dev/tests/e2e/base-tests/config/outcome-markers.json b/dev/tests/e2e/base-tests/config/outcome-markers.json new file mode 100644 index 00000000000..ec363ab8ed6 --- /dev/null +++ b/dev/tests/e2e/base-tests/config/outcome-markers.json @@ -0,0 +1,86 @@ +{ + "account": { + "accountCreatedNotificationText": "Thank you for registering with Main Website Store.", + "accountPageTitle": "Account Information", + "addressBookTitle": "Customer Address", + "changedCredentialsInformation": "You saved the account", + "createAccountHeaderText" : "Create New Customer Account", + "newsletterRemovedNotification": "We have removed your newsletter subscription.", + "newsletterSavedNotification": "We have saved your subscription.", + "newsletterSubscriptionTitle": "Newsletter Subscription", + "newsletterUpdatedNotification": "We have updated your subscription." + }, + "address": { + "addressDeletedNotification": "You deleted the address.", + "newAddressAddedNotifcation": "You saved the address." + }, + "adminGeneral": { + "searchResultsFoundText": "records found", + "activeFiltersText": "Active filters" + }, + "cart": { + "discountAppliedNotification": "You used coupon code", + "discountRemovedNotification": "You canceled the coupon code.", + "incorrectCouponCodeNotificationOne": "The coupon code", + "incorrectCouponCodeNotificationTwo": "is not valid.", + "priceReducedSymbols": "$" + }, + "categoryPage" : { + "subCategoryPageTitle" : "Fitness Equipment" + }, + "checkout": { + "checkoutPriceReducedSymbol": "-$", + "couponAppliedNotification": "Your coupon was successfully applied", + "couponRemovedNotification": "Your coupon was successfully removed", + "incorrectDiscountNotification": "The coupon code isn't valid. Verify the code and try again.", + "orderPlacedNotification": "Thank you for your purchase!", + "orderPlacedNumberText": "Your order number is:" + }, + "comparePage": { + "productRemovedNotificationTextOne": "You removed product", + "productRemovedNotificationTextTwo": "from the comparison list.", + "productNotWishlistedNotificationText": "You must login or register to add items to your wishlist." + }, + "contactPage": { + "messageSentConfirmationText": "Thanks for contacting us with" + }, + "customerOverviewPage": { + "searchResultsFoundText": "records found" + }, + "footerPage": { + "newsletterSubscription": "Thank you for your subscription.", + "newsletterAlreadySubscribed": "This email address is already subscribed." + }, + "homePage": { + "firstProductName": "Aim Analog Watch" + }, + "logout": { + "logoutConfirmationText": "You have signed out" + }, + "magentoAdmin" : { + "configurationSavedText" : "You saved the configuration.", + "couponRuleSavedText" : "You saved the rule.", + "noResultsFoundText" : "We couldn't find any records." + }, + "miniCart": { + "configurableProductMinicartTitle": "x Inez Full Zip Jacket", + "miniCartTitle": "My Cart", + "productQuantityChangedConfirmation": "was updated in your shopping cart", + "productRemovedConfirmation": "You removed the item.", + "simpleProductInCartTitle": "x Push It Messenger Bag" + }, + "productPage": { + "borderClassRegex": ".* border-primary$", + "simpleProductAddedNotification": "You added" + }, + "login": { + "loginHeaderText" : "Login", + "invalidCredentialsMessage": "The account sign-in was incorrect or your account is disabled temporarily. Please wait and try again later." + }, + "search": { + "noResultsMessage": "Your search returned no results." + }, + "wishListPage": { + "wishListAddedNotification": "has been added to your Wish List." + } +} diff --git a/dev/tests/e2e/base-tests/config/slugs.json b/dev/tests/e2e/base-tests/config/slugs.json new file mode 100644 index 00000000000..906fb2015b0 --- /dev/null +++ b/dev/tests/e2e/base-tests/config/slugs.json @@ -0,0 +1,43 @@ +{ + "account": { + "accountOverviewSlug": "/customer/account/", + "accountOverviewRegex": "/.*\\/customer\\/account\\/.*/", + "addressBookSlug": "/customer/address", + "addressIndexSlug": "/customer/address/index", + "addressNewSlug": "/customer/address/new", + "accountEditSlug": "/customer/account/edit/", + "changePasswordSlug": "/customer/account/edit/changepass/1/", + "createAccountSlug": "/customer/account/create/", + "loginSlug": "/customer/account/login/", + "loginSlugRegex": ".*\\/customer\\/account\\/login.*", + "orderHistorySlug": "/sales/order/history/" + }, + "cart": { + "cartProductChangeSlug": "/cart/configure/", + "cartSlug": "/checkout/cart/" + }, + "categoryPage": { + "categorySlug": "/women.html", + "subcategorySlug" : "/gear/fitness-equipment.html" + }, + "checkout": { + "checkoutSlug": "/checkout/", + "purchaseSuccessSlug": "/checkout/onepage/success/" + }, + "contact": { + "contactSlug": "/contact" + }, + "productPage": { + "configurableProductSlug": "/inez-full-zip-jacket.html", + "productComparisonSlug": "/catalog/product_compare/index/", + "secondSimpleProductSlug": "/aim-analog-watch.html", + "simpleProductSlug": "/push-it-messenger-bag.html" + }, + "search": { + "resultsSlug": "/catalogsearch/result/" + }, + "wishList": { + "wishListSlug": "/wishlist/", + "wishListRegex": ".*wishlist.*" + } +} diff --git a/dev/tests/e2e/base-tests/config/test-toggles.json b/dev/tests/e2e/base-tests/config/test-toggles.json new file mode 100644 index 00000000000..967305142bf --- /dev/null +++ b/dev/tests/e2e/base-tests/config/test-toggles.json @@ -0,0 +1,5 @@ +{ + "general": { + "setup": false + } +} diff --git a/dev/tests/e2e/base-tests/footer.spec.ts b/dev/tests/e2e/base-tests/footer.spec.ts new file mode 100644 index 00000000000..f846f20f97f --- /dev/null +++ b/dev/tests/e2e/base-tests/footer.spec.ts @@ -0,0 +1,51 @@ +// @ts-check + +import { test } from '@playwright/test'; +import { outcomeMarker } from '@config'; +import NotificationValidatorUtils from "@utils/notificationValidator.utils"; +import NewsletterPage from "@poms/frontend/newsletter.page"; +import {requireEnv} from "@utils/env.utils"; + +import Footer from '@poms/frontend/footer.page'; + +test.describe('Footer', () => { + + test( + 'Footer_is_available', + {tag: ['@footer', '@cold']}, + async ({page}) => { + const footer = new Footer(page); + + await page.goto(''); + await footer.goToFooterElement(); + } + ) + + test( + 'Footer_switch_currency', + {tag: ['@footer', '@cold']}, + async ({page}) => { + const footer = new Footer(page); + + await page.goto(''); + await footer.switchCurrency(); + } + ) + + test( + 'Footer_newsletter_subscription', + {tag: ['@footer', '@cold']}, + async ({page}, testInfo) => { + const newsletterPage = new NewsletterPage(page); + + await page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + await newsletterPage.footerSubscribeToNewsletter(); + + const subscriptionOutput = outcomeMarker.footerPage.newsletterSubscription; + const notificationType = 'Newsletter subscription notification'; + + const notificationValidator = new NotificationValidatorUtils(page, testInfo); + await notificationValidator.validate(subscriptionOutput); + } + ) +}) diff --git a/dev/tests/e2e/base-tests/healthcheck.spec.ts b/dev/tests/e2e/base-tests/healthcheck.spec.ts new file mode 100644 index 00000000000..6d39ff3d563 --- /dev/null +++ b/dev/tests/e2e/base-tests/healthcheck.spec.ts @@ -0,0 +1,64 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { UIReference, slugs } from '@config'; + +test.describe('Page health checks', () => { + test('Homepage_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { + const homepageURL = process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL; + if (!homepageURL) { + throw new Error("PLAYWRIGHT_BASE_URL has not been defined in the .env file."); + } + + const homepageResponsePromise = page.waitForResponse(homepageURL); + await page.goto(homepageURL); + const homepageResponse = await homepageResponsePromise; + expect(homepageResponse.status(), 'Homepage should return 200').toBe(200); + + await expect( + page.getByRole('heading', {name: UIReference.homePage.homePageTitleText, level: 1}), + 'Homepage has a visible title' + ).toBeVisible(); + }); + + test('Plp_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { + const plpResponsePromise = page.waitForResponse(slugs.categoryPage.categorySlug); + await page.goto(slugs.categoryPage.categorySlug); + const plpResponse = await plpResponsePromise; + expect(plpResponse.status(), 'PLP should return 200').toBe(200); + + await expect( + page.getByRole('heading', {name: UIReference.categoryPage.categoryPageTitleText}), + 'PLP has a visible title' + ).toBeVisible(); + }); + + test('Pdp_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { + const pdpResponsePromise = page.waitForResponse(slugs.productPage.simpleProductSlug); + await page.goto(slugs.productPage.simpleProductSlug); + const pdpResponse = await pdpResponsePromise; + expect(pdpResponse.status(), 'PDP should return 200').toBe(200); + + await expect( + page.getByRole('heading', {level: 1, name: UIReference.productPage.simpleProductTitle}), + 'PDP has a visible title' + ).toBeVisible(); + }); + + test('Checkout_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { + const responsePromise = page.waitForResponse(slugs.checkout.checkoutSlug); + + await page.goto(slugs.checkout.checkoutSlug); + const response = await responsePromise; + + expect(response.status(), 'Cart empty, checkout should return 302').toBe(302); + expect(page.url(), 'Cart empty, checkout should redirect to cart').toContain(slugs.cart.cartSlug); + + await expect( + page.getByRole('heading', {name: UIReference.cart.cartTitleText}), + 'Cart has a visible title' + ).toBeVisible(); + + expect((await page.request.head(page.url())).status(), `Current page (${page.url()}) should return 200`).toBe(200); + }); +}); diff --git a/dev/tests/e2e/base-tests/home.spec.ts b/dev/tests/e2e/base-tests/home.spec.ts new file mode 100644 index 00000000000..f7bbb80af98 --- /dev/null +++ b/dev/tests/e2e/base-tests/home.spec.ts @@ -0,0 +1,17 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { outcomeMarker } from '@config'; + +import MainMenuPage from '@poms/frontend/mainmenu.page'; +import HomePage from '@poms/frontend/home.page'; + +test('Add_product_on_homepage_to_cart',{ tag: ['@homepage', '@cold']}, async ({page}) => { + const homepage = new HomePage(page); + const mainmenu = new MainMenuPage(page); + + await page.goto(''); + await homepage.addHomepageProductToCart(); + await mainmenu.openMiniCart(); + await expect(page.getByText('x ' + outcomeMarker.homePage.firstProductName), 'product should be visible in cart').toBeVisible(); +}); diff --git a/dev/tests/e2e/base-tests/login.spec.ts b/dev/tests/e2e/base-tests/login.spec.ts new file mode 100644 index 00000000000..29ac85772d7 --- /dev/null +++ b/dev/tests/e2e/base-tests/login.spec.ts @@ -0,0 +1,47 @@ +// @ts-check + +import { test as base, expect } from '@playwright/test'; +import { outcomeMarker, inputValues } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +import LoginPage from '@poms/frontend/login.page'; + +base('User_logs_in_with_valid_credentials', {tag: '@hot'}, async ({page, browserName}) => { + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + // We can't move this browser specific check inside LoginPage because the + // variable name differs per browser engine. + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + const loginPage = new LoginPage(page) + await loginPage.login(emailInputValue, passwordInputValue); + await page.waitForLoadState('networkidle'); + + // Check customer section data in localStorage and verify name + const customerData = await page.evaluate(() => { + const data = localStorage.getItem('mage-cache-storage'); + return data ? data : null; + }); + + expect(customerData, 'Customer data should exist in localStorage').toBeTruthy(); + expect(customerData, 'Customer data should contain customer information').toContain('customer'); + + // Parse the JSON and verify firstname and lastname + const parsedData = await page.evaluate(() => { + const data = localStorage.getItem('mage-cache-storage'); + return data ? JSON.parse(data) : null; + }); + + expect(parsedData.customer.firstname, 'Customer firstname should match').toBe(inputValues.accountCreation.firstNameValue); + expect(parsedData.customer.fullname, 'Customer lastname should match').toContain(inputValues.accountCreation.lastNameValue); +}); + +base('Invalid_credentials_are_rejected', async ({page}) => { + const loginPage = new LoginPage(page); + await loginPage.loginExpectError('invalid@example.com', 'wrongpassword', outcomeMarker.login.invalidCredentialsMessage); +}); + +base('Login_fails_with_missing_password', async ({page}) => { + const loginPage = new LoginPage(page); + await loginPage.loginExpectError('invalid@example.com', '', ''); +}); diff --git a/dev/tests/e2e/base-tests/mainmenu.spec.ts b/dev/tests/e2e/base-tests/mainmenu.spec.ts new file mode 100644 index 00000000000..8d06996a465 --- /dev/null +++ b/dev/tests/e2e/base-tests/mainmenu.spec.ts @@ -0,0 +1,122 @@ +// @ts-check +/** + * Copyright Elgentos. All rights reserved. + * https://elgentos.nl/ + * + * @fileoverview various tests to confirm menu functionality. + */ + +// Import test and expect from utils to ensure authenticated state. +import { test } from '@utils/fixtures.utils'; +import { requireEnv } from '@utils/env.utils'; + +import { inputValues} from '@config'; + +import MainMenuPage from '@poms/frontend/mainmenu.page'; + +test.describe('User tests (logged in)', () => { + // Authentication is handled by the storage state fixture (fixtures.utils). + // Each POM method navigates to the homepage and waits for customer section data. + + /** + * Test: a user logs out, using the menu + * @assume the user is already logged in + * @param page - Playwright page instance used to interact with the website. + */ + test('User_logs_out', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.logout(); + }); + + /** + * Test: a user navigates to their account page, using the menu + * @assume the user is already logged in + * @param page - Playwright page instance used to interact with the website. + */ + test('Navigate_to_account_page', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.openAccountMenu(true); + await mainMenu.gotoMyAccount(); + }); + + /** + * Test: a user navigates to their wishlist, using the menu + * @assume the user is already logged in + * @param page - Playwright page instance used to interact with the website. + */ + test('Navigate_to_wishlist', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.openAccountMenu(true); + await mainMenu.goToWishList(); + }); + + /** + * Test: a user navigates to their order history, using the menu + * @assume the user is already logged in + * @param page - Playwright page instance used to interact with the website. + */ + test('Navigate_to_orders', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.openAccountMenu(true); + await mainMenu.goToOrders(); + }); + + /** + * Test: a user navigates to their address book, using the menu + * @assume the user is already logged in + * @param page - Playwright page instance used to interact with the website. + */ + test('Navigate_to_address_book', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.openAccountMenu(true); + await mainMenu.goToAddressBook(); + }); +}); + +test.describe('Guest tests (not logged in)', () => { + // We're using the authenticated fixture, we need to log out explicitly. + test.beforeEach(async({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.logout(); + }); + + /** + * Test: a guest navigates to a category page, using the menu + * @param page - Playwright page instance used to interact with the website. + */ + test('Navigate_to_category_page', { tag: ['@mainmenu', '@cold'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.goToCategoryPage(); + }); + + /** + * Test: a guest navigates to a subcategory page, using the menu + * @param page - Playwright page instance used to interact with the website. + */ + test('Navigate_to_subcategory_page', { tag: ['@mainmenu', '@cold'] }, async ({page, browserName}) => { + test.skip(browserName === 'firefox', 'Skipped due to known issue: https://github.com/microsoft/playwright/issues/27969'); + const mainMenu = new MainMenuPage(page); + await mainMenu.goToSubCategoryPage(); + }); + + /** + * Test: a guest opens the mini cart in the menu + * @param page - Playwright page instance used to interact with the website. + */ + test('Open_the_minicart', { tag: ['@mainmenu', '@cold'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + await mainMenu.mainMenuMiniCartButton.waitFor(); + await mainMenu.openMiniCart(); + }); + + /** + * Test: a guest uses the search function to search for products. + * @param page - Playwright page instance used to interact with the website. + */ + test.fixme('User_searches_for_product', { tag: ['@mainmenu', '@cold'] }, async ({page}) => { + test.info().annotations.push({type: `fixme notice`, description: `See ticket 414 in Gitlab.`}); + const mainMenu = new MainMenuPage(page); + await mainMenu.searchForProduct(inputValues.search.queryMultipleResults); + }); +}); diff --git a/dev/tests/e2e/base-tests/minicart.spec.ts b/dev/tests/e2e/base-tests/minicart.spec.ts new file mode 100644 index 00000000000..d3cb449953f --- /dev/null +++ b/dev/tests/e2e/base-tests/minicart.spec.ts @@ -0,0 +1,117 @@ +// @ts-check + +import {test, expect} from '@playwright/test'; +import {UIReference, outcomeMarker, slugs} from '@config'; + +import MainMenuPage from '@poms/frontend/mainmenu.page'; +import ProductPage from '@poms/frontend/product.page'; +import MiniCartPage from '@poms/frontend/minicart.page'; + +test.describe('Minicart Actions', {annotation: {type: 'Minicart', description: 'Minicart simple product tests'},}, () => { + + /** + * @feature BeforeEach runs before each test in this group. + * @scenario Add a product to the cart and confirm it's there. + * @given I am on any page + * @when I navigate to a (simple) product page + * @and I add it to my cart + * @then I should see a notification + * @when I click the cart in the main menu + * @then the minicart should become visible + * @and I should see the product in the minicart + */ + test.beforeEach(async ({ page }) => { + const mainMenu = new MainMenuPage(page); + const productPage = new ProductPage(page); + + await page.goto(slugs.productPage.simpleProductSlug); + await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + await mainMenu.openMiniCart(); + await expect(page.getByText(outcomeMarker.miniCart.simpleProductInCartTitle)).toBeVisible(); + }); + + /** + * @feature Magento 2 Minicart to Checkout + * @scenario User adds a product to cart, then uses minicart to navigate to checkout + * @given I have added a (simple) product to the cart and opened the minicart + * @when I click on the 'to checkout' button + * @then I should navigate to the checkout page + */ + + test('Add_product_to_minicart_and_go_to_checkout',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { + const miniCart = new MiniCartPage(page); + await miniCart.goToCheckout(); + }); + + /** + * @feature Magento 2 Minicart to Cart + * @scenario User adds a product to cart, then uses minicart to navigate to their cart + * @given I have added a (simple) product to the cart and opened the minicart + * @when I click on the 'to cart' link + * @then I should be navigated to the cart page + */ + + test('Add_product_to_minicart_and_go_to_cart',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { + const miniCart = new MiniCartPage(page); + await miniCart.goToCart(); + }); + + /** + * @feature Magento 2 minicart product deletion + * @scenario User adds product to cart, then removes from minicart + * @given I have added a (simple) product to the cart and opened the minicart + * @when I click on the delete button + * @then The product should not be in my cart anymore + * @and I should see a notification that the product was removed + */ + test('Delete_product_from_minicart',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}, testInfo) => { + 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.`}); + const miniCart = new MiniCartPage(page); + await miniCart.removeProductFromMinicart(UIReference.productPage.simpleProductTitle); + }); + + /** + * @feature Price Check: Simple Product on Product Detail Page (PDP) and Minicart + * @scenario The price on a PDP should be the same as the price in the minicart + * @given I have added a (simple) product to the cart and opened the minicart + * @then the price listed in the minicart (per product) should be the same as the price on the PDP + */ + test('Pdp_price_matches_minicart_price',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { + const miniCart = new MiniCartPage(page); + await miniCart.checkPriceWithProductPage(); + }); +}); + +test.describe('Minicart Actions', {annotation: {type: 'Minicart', description: 'Minicart configurable product tests'},}, () => { + /** + * @feature BeforeEach runs before each test in this group. + * @scenario Add a configurable product to the cart and confirm it's there. + * @given I am on any page + * @when I navigate to a (simple) product page + * @and I add it to my cart + * @then I should see a notification + * @when I click the cart in the main menu + * @then the minicart should become visible + * @and I should see the product in the minicart + */ + test.beforeEach(async ({ page }) => { + const mainMenu = new MainMenuPage(page); + const productPage = new ProductPage(page); + + await page.goto(slugs.productPage.configurableProductSlug); + await productPage.addConfigurableProductToCart(UIReference.productPage.configurableProductTitle, slugs.productPage.configurableProductSlug, '2'); + await mainMenu.openMiniCart(); + await expect(page.getByText(outcomeMarker.miniCart.configurableProductMinicartTitle)).toBeVisible(); + }); + + /** + * @feature Price Check: Configurable Product on Product Detail Page (PDP) and Minicart + * @scenario The price on a PDP should be the same as the price in the minicart + * @given I have added a (configurable) product to the cart and opened the minicart + * @then the price listed in the minicart (per product) should be the same as the price on the PDP + */ + test('Configurable_pdp_price_matches_minicart_price',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { + const miniCart = new MiniCartPage(page); + await miniCart.checkPriceWithProductPage(); + }); +}); diff --git a/dev/tests/e2e/base-tests/poms/admin/adminlogin.page.ts b/dev/tests/e2e/base-tests/poms/admin/adminlogin.page.ts new file mode 100644 index 00000000000..e3402d33aea --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/admin/adminlogin.page.ts @@ -0,0 +1,194 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { requireEnv } from '@utils/env.utils'; +import { UIReference } from '@config'; + + +class AdminLogin { + // General + readonly page: Page; + readonly pageHeadingOne: Locator; + readonly saveConfigButton: Locator; + // Input Fields + readonly adminLoginEmailField: Locator; + readonly adminLoginPasswordField: Locator; + readonly adminLoginButton: Locator; + // Navigation + readonly mainMenuStoresButton: Locator; + readonly storesConfigurationButton: Locator; + readonly storesCustomersTab: Locator; + readonly advancedSettingsTab: Locator; + readonly customerConfigurationLink: Locator; + readonly adminSettingsLink:Locator; + // Settings + readonly customerCaptchaAccordion: Locator; + readonly adminSecurityAccordion: Locator; + readonly storeFrontCaptchaOption: Locator; + readonly adminSharingOption: Locator; + readonly customerCAPTCHAInheritCheckbox: Locator; + readonly adminInheritCheckbox: Locator; + + + constructor(page: Page) { + // General + this.page = page; + this.pageHeadingOne = page.locator(UIReference.general.headingOneLocator); + this.saveConfigButton = page.getByRole('button', { name: UIReference.general.saveConfigButton }); + // Input Fields + this.adminLoginEmailField = page.locator(UIReference.authentication.adminUsernameFieldId); + this.adminLoginPasswordField = page.locator(UIReference.authentication.adminPasswordFieldId); + this.adminLoginButton = page.locator(UIReference.authentication.adminLoginButtonClass); + // Navigation + this.mainMenuStoresButton = page.getByRole('link', {name: UIReference.admin.storesButton}); + this.storesConfigurationButton = page.getByRole('link', {name: UIReference.admin.configuration}).first(); + this.storesCustomersTab = page.locator(UIReference.admin.configTabLocator).getByText(UIReference.admin.customers); + this.advancedSettingsTab = page.getByRole('strong').filter({hasText: UIReference.admin.advanced}); + this.customerConfigurationLink = page.getByRole('link', { name: UIReference.admin.customerConfiguration }); + this.adminSettingsLink = page.getByRole('link', {name: UIReference.admin.admin, exact: true}); + // Settings + this.customerCaptchaAccordion = page.getByRole('link', { name: 'CAPTCHA' }).filter({hasNotText: 'documentation'}); + this.adminSecurityAccordion = page.getByRole('link', { name: UIReference.general.security }); + this.storeFrontCaptchaOption = page.getByLabel(UIReference.admin.captchaEnabled); + this.adminSharingOption = page.getByLabel(UIReference.admin.adminSharing); + this.customerCAPTCHAInheritCheckbox = page.locator(UIReference.admin.customerCAPTCHAInheritLocator); + this.adminInheritCheckbox = page.locator(UIReference.admin.customerInheritLocator); + } + + /** + * Disable the CAPTCHAs that prevent Playwright tests from functioning. + */ + async disableLoginCaptcha(){ + await this.storesCustomersTab.click(); + // Confirm the link for customer configuration is visible. + await expect(async() => { + await expect(this.customerConfigurationLink, `"Customer Configuration" link is visible`).toBeVisible(); + }).toPass(); + + await this.customerConfigurationLink.click(); + + if(!await this.storeFrontCaptchaOption.isVisible()){ + // option not visible, tab is closed. + await this.customerCaptchaAccordion.click(); + // Confirm captcha option is now open + await expect(this.storeFrontCaptchaOption, `"enable CAPTCHA on storefront" option is open`).toBeVisible(); + } + + // if the 'use system value' checkbox is checked, uncheck it. + if(await this.customerCAPTCHAInheritCheckbox.isChecked()) { + await this.customerCAPTCHAInheritCheckbox.uncheck(); + await expect(this.storeFrontCaptchaOption, `CAPTCHA option can be changed`).toBeEnabled(); + } + + // check if CAPTCHA is already disabled + if(await this.storeFrontCaptchaOption.inputValue() == '0'){ + await expect(this.storeFrontCaptchaOption, `CAPTCHA is disabled for customers`).toHaveValue('0'); + } else { + // Disabled the CAPTCHA + await this.storeFrontCaptchaOption.selectOption('0'); + await expect(this.storeFrontCaptchaOption, `CAPTCHA is disabled for customers`).toHaveValue('0'); + + await this.saveConfigButton.click(); + await expect(this.page.locator(UIReference.general.adminMessageLocator), + `Notification "Configuration Saved" is visible.`).toContainText(UIReference.admin.configurationSavedText); + } + } + + /** + * Navigate to the Stores Settings in Magento Admin. + */ + async navigateToStoreSettings() { + const configurationPageLabel = UIReference.admin.configuration; + await this.mainMenuStoresButton.click(); + await this.storesConfigurationButton.click(); + + // Confirm the page has loaded correctly by checking for the presence of text. + await expect(async () => { + await expect(this.pageHeadingOne, `Page title is '${configurationPageLabel}'`) + .toContainText(`${configurationPageLabel}`); + + await expect(this.page.getByRole('link', {name: UIReference.general.general}), + `"General options" under General section is visible.`).toBeVisible(); + }).toPass(); + } + + /** + * Enable multiple admin logins + * Assumption is that we're in the 'Store Settings'. + */ + async enableMultipleAdminLogins() { + await this.advancedSettingsTab.click(); + // Confirm the link for 'admin' settings is visible. + await expect(async() => { + await expect(this.adminSettingsLink, `"Admin" link under "Advanced" is visible`).toBeVisible(); + }).toPass(); + + await this.adminSettingsLink.click(); + + if(!await this.adminSharingOption.isVisible()){ + // tab is closed. + await this.adminSecurityAccordion.click(); + await expect(this.adminSharingOption, `Security tab is opened`).toBeVisible(); + } + + // if the 'use system value' checkbox is checked, uncheck it. + if(await this.adminInheritCheckbox.isChecked()) { + await this.adminInheritCheckbox.uncheck(); + await expect(this.adminSharingOption, `Admin Account Sharing option can be changed`).toBeEnabled(); + } + + // check if Admin Account Sharing is already available + if(await this.adminSharingOption.inputValue() == '1'){ + await expect(this.adminSharingOption, `Account sharing option enabled`).toHaveValue('1'); + } else { + // Enable account sharing + await this.adminSharingOption.selectOption('1'); + await expect(this.adminSharingOption, `Account sharing option enabled`).toHaveValue('1'); + + await this.saveConfigButton.click(); + await expect(this.page.locator(UIReference.general.adminMessageLocator), + `Notification "Configuration Saved" is visible.`).toContainText(UIReference.admin.configurationSavedText); + } + + } + + /** + * Log the admin user in to set up the Magento 2 environment + * @param username - admin's username, sourced from .env + * @param password - admin's password, sourced from .env + */ + async loginAdmin(username:string, password:string){ + const dashboardLabel = this.page.getByRole('heading', {name: UIReference.titles.adminDashboardHeading}); + const captchaNotification = this.page.locator(UIReference.general.messageLocator).filter( + {hasText : UIReference.errors.captchaIncorrect} + ); + const adminLoginHeading = this.page.getByText(UIReference.authentication.adminLoginText); + + if(await dashboardLabel.isVisible()){ + // already logged in + return; + } + + await this.page.goto(`${requireEnv(`MAGENTO_ADMIN_SLUG`)}`, { waitUntil: 'load'}); + + // Confirm the page has loaded correctly by checking for the presence of text. + await expect(async() => { + await expect(adminLoginHeading, `"Please sign in" text is visible`).toBeVisible(); + }).toPass(); + + await this.adminLoginEmailField.fill(username); + await this.adminLoginPasswordField.fill(password); + await this.adminLoginButton.click(); + + if(await captchaNotification.isVisible()){ + throw new Error(`CAPTCHA field found, automated login failed.`); + } + + // Confirm the page has loaded correctly by checking for the presence of text. + await expect(async() => { + await expect(dashboardLabel, `Dashboard Title is visible`).toBeVisible(); + }).toPass(); + } +} + +export default AdminLogin; diff --git a/dev/tests/e2e/base-tests/poms/admin/customers.page.ts b/dev/tests/e2e/base-tests/poms/admin/customers.page.ts new file mode 100644 index 00000000000..c71b97f51e1 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/admin/customers.page.ts @@ -0,0 +1,188 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, inputValues, outcomeMarker } from '@config'; +import { requireEnv } from "@utils/env.utils"; + +class AdminCustomers { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * @feature Customer Management + * @scenario Check if a customer exists by email address + * @given the admin is on the Magento dashboard + * @when the admin navigates to Customers > All Customers + * @and the customer table is fully loaded + * @and the admin searches for a specific email address + * @then reset the table filter + * @then the system returns whether a customer with that email exists in the customer list + */ + async checkIfCustomerExists(email: string){ + const mainMenuCustomersButton = this.page.getByRole('link', {name: UIReference.adminPage.navigation.customersButtonLabel}).first(); + const allCustomersLink = this.page.getByRole('link', {name: UIReference.adminPage.subNavigation.allCustomersButtonLabel}); + const customersSearchField = this.page.getByRole('textbox', {name: UIReference.customerOverviewPage.tableSearchFieldLabel}); + + // loop clicking the 'Customers' button until clicking it show the subnavigation + await expect(async() =>{ + await mainMenuCustomersButton.press('Enter'); + await expect(allCustomersLink, `Link to "All Customers" is visible`).toBeVisible({timeout: 5000}); + }).toPass(); + + await allCustomersLink.click(); + + // Wait for URL. If loading symbol is visible, wait for it to go away + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/**`); + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + await customersSearchField.waitFor(); + await customersSearchField.fill(email); + await this.page.getByRole('button', {name: UIReference.general.searchButtonLabel}).click(); + + // Wait for the loader spinner to be hidden + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Loop to ensure the 'results found' text is visible + await expect( + this.page.getByText(outcomeMarker.adminGeneral.activeFiltersText).first(), + "There are active filters." + ).toBeVisible(); + + // Return true (email found) or false (email not found) + const emailIsFound = await this.page.getByRole('cell', {name:email}).locator('div').isVisible(); + + // Click 'Clear all' button on filtered table to reset the table state. + await this.page.getByRole('button', {name: UIReference.adminGeneral.tableFilterResetLabel}).click(); + + // Wait for the loader spinner to be hidden + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + await expect( + this.page.getByText(outcomeMarker.adminGeneral.activeFiltersText).first(), + "There are no filters active." + ).toBeHidden(); + + return emailIsFound; + } + + /** + * @feature Customer Management + * @scenario Create a new customer account + * @given the admin is on the Magento dashboard + * @when the admin navigates to Customers > All Customers + * @and clicks the 'Create New Customer' button + * @then the admin fills in the mandatory fields and optional fields for a new customer account + * @and the system saves the customer account and navigates to the account edit page + * @and displays a confirmation message that the customer was saved + */ + async createNewCustomerAccount( + firstName: string, + lastName: string, + email: string + ) { + const createNewCustomersLink = this.page.getByRole('button', {name: UIReference.adminCustomers.createNewCustomerButtonLabel}); + await createNewCustomersLink.click(); + + // Wait for URL. If loading symbol is visible, wait for it to go away + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/new/**`); + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + const accountCreationFirstNameField = this.page.getByLabel(UIReference.personalInformation.firstNameLabel); + const accountCreationLastNameField = this.page.getByLabel(UIReference.personalInformation.lastNameLabel); + const accountCreationEmailField = this.page.getByLabel(UIReference.credentials.emailFieldLabel, { exact: true}); + const accountCreationConfirmButton = this.page.getByRole('button', {name: UIReference.adminCustomers.registration.createAccountSaveAndContinueButtonLabel}); + const customersSearchField = this.page.getByRole('textbox', {name: UIReference.adminGeneral.tableSearchFieldLabel}); + + // Optional fields: + const allowBulkPurchaseSwitcher = this.page.locator(UIReference.cartPriceRulesPage.activeStatusSwitcherLocator).first(); + + await accountCreationFirstNameField.fill(firstName); + await accountCreationLastNameField.fill(lastName); + await accountCreationEmailField.fill(email); + await allowBulkPurchaseSwitcher.click(); + await accountCreationConfirmButton.click(); + + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/edit/**`); + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + + await expect( + this.page.locator(UIReference.general.messageLocator).filter({hasText: 'You saved the customer.'}) + ).toBeVisible(); + } + + await this.approveAccount(email); + } + + /** + * @feature Customer Management + * @scenario Approve a customer account + * @given the admin is on the Magento dashboard + * @when the admin navigates to Customers > All Customers + * @and searches for a specific email address + * @then the admin clicks on the 'Edit' link for the corresponding customer + * @and approves the customer account + * @and the system displays a confirmation message that the customer account has been approved + */ + async approveAccount(email: string) { + + const customersSearchField = this.page.getByRole('textbox', {name: UIReference.adminGeneral.tableSearchFieldLabel}); + const editAccountButton = this.page.getByRole('link', {name: 'Edit'}).first() + const approvalButtonAccountEdit = this.page.getByRole('button', {name: 'Approve'}) + + await customersSearchField.waitFor(); + await customersSearchField.fill(email); + await this.page.getByRole('button', {name: UIReference.adminGeneral.searchButtonLabel}).click(); + + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Loop to ensure the 'results found' text is visible + await expect(async() =>{ + await this.page.getByText(outcomeMarker.customerOverviewPage.searchResultsFoundText).first(); + }).toPass(); + + // Return true (email found) or false (email not found) + await this.page.getByRole('cell', {name:email}).locator('div').isVisible(); + + await expect(async() => { + editAccountButton.click(); + }).toPass(); + + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/edit/**`); + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + console.log('Spinner is visible'); + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Press approval button when approval button is visible + if (await approvalButtonAccountEdit.isVisible()) { + await approvalButtonAccountEdit.click(); + + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/edit/**`); + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + console.log('Spinner is visible'); + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + await expect( + this.page + .locator(UIReference.general.messageLocator) + .filter({ hasText: 'Customer account has been approved!' }) + ).toBeVisible(); + } + } +} + +export default AdminCustomers; \ No newline at end of file diff --git a/dev/tests/e2e/base-tests/poms/admin/marketing.page.ts b/dev/tests/e2e/base-tests/poms/admin/marketing.page.ts new file mode 100644 index 00000000000..452a38abfc7 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/admin/marketing.page.ts @@ -0,0 +1,128 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import {UIReference, inputValues, outcomeMarker} from '@config'; + +class AdminMarketing { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * @feature Cart Price Rules Configuration + * @scenario Add or activate a cart price rule with a specific coupon code + * @given the admin is on the Magento dashboard + * @when the coupon exists but is inactive + * @then the admin activates the existing coupon and saves the rule + * @but if the coupon does not exist + * @then the admin creates a new cart price rule with the given coupon code + * @and selects all websites and customer groups + * @and sets the coupon type and discount amount + * @and clicks the Save button + * @then the system displays a success message confirming the rule was saved + */ + async addCartPriceRule(magentoCouponCode: string){ + let resultMessage = ""; + + // Force specific viewport size to deal with webkit issues + await this.page.setViewportSize({ + width: 1920, + height: 1080 + }) + + const mainMenuMarketingButton = this.page.getByRole('link', {name: UIReference.adminPage.navigation.marketingButtonLabel}); + const cartPriceRulesLink = this.page.getByRole('link', {name: UIReference.adminPage.subNavigation.cartPriceRulesButtonLabel}); + await expect(mainMenuMarketingButton, `Button for Marketing is visible`).toBeVisible(); + + await expect(async () => { + await mainMenuMarketingButton.click(); + await expect(cartPriceRulesLink, `Button for Cart Price Rules is visible`).toBeVisible(); + }).toPass(); + + await cartPriceRulesLink.click(); + + const addCartPriceRuleButton = this.page.getByRole('button', {name: UIReference.cartPriceRulesPage.addCartPriceRuleButtonLabel}); + await addCartPriceRuleButton.waitFor(); + + // Use search field to check for coupon codes. + const couponSearchField = this.page.locator(UIReference.adminPage.couponSearchFieldLocator); + await couponSearchField.fill(magentoCouponCode); + await this.page.getByRole('button', {name: UIReference.general.searchButtonLabel}).click(); + + await expect(this.page.getByText(UIReference.adminPage.searchResultsText), `Search results text visible`).toBeVisible(); + + const couponCellField = this.page.getByRole('cell', { name: outcomeMarker.magentoAdmin.noResultsFoundText }); + + if(await couponCellField.isHidden()){ + const couponStatusField = this.page.locator('tr').filter({hasText:magentoCouponCode}).first(); + const couponStatus = await couponStatusField.innerText(); + if(couponStatus.includes(UIReference.cartPriceRulesPage.couponCodeActiveStatusText)){ + resultMessage = 'Coupon already exists and is active.'; + } else { + // coupon has been found, but is not active. + await couponStatusField.click(); + const activeStatusSwitcher = this.page.locator(UIReference.cartPriceRulesPage.activeStatusSwitcherLocator).first(); + const activeStatusLabel = this.page.locator(UIReference.cartPriceRulesPage.activeStatusLabelLocator).first(); + + await expect(activeStatusLabel, `Active/Disable toggle is visible`).toBeVisible(); + await activeStatusSwitcher.click(); + + const saveCouponButton = this.page.getByRole('button', {name:UIReference.cartPriceRulesPage.saveRuleButtonLabel, exact:true}); + await saveCouponButton.click(); + + await expect(this.page.locator( + UIReference.general.messageLocator).filter({hasText: outcomeMarker.magentoAdmin.couponRuleSavedText} + ), "Message 'you saved the rule' is visible").toBeVisible(); + resultMessage = `Coupon code ${magentoCouponCode} has been activated.`; + } + } else { + // coupon is not set + await addCartPriceRuleButton.click(); + + const websiteSelector = this.page.getByLabel(UIReference.cartPriceRulesPage.websitesSelectLabel); + await websiteSelector.evaluate(select => { + const s = select as HTMLSelectElement; + for (const option of s.options) { + option.selected = true; + } + select.dispatchEvent(new Event('change')); + }); + + const customerGroupsSelector = this.page.getByLabel(UIReference.cartPriceRulesPage.customerGroupsSelectLabel, { exact: true }); + await customerGroupsSelector.evaluate(select => { + const s = select as HTMLSelectElement; + for (const option of s.options) { + option.selected = true; + } + select.dispatchEvent(new Event('change')); + }); + + await this.page.getByRole('textbox', { name: UIReference.cartPriceRulesPage.ruleNameFieldLabel }).fill(magentoCouponCode); + await this.page.locator(UIReference.cartPriceRulesPage.couponTypeSelectField).selectOption({ label: inputValues.coupon.couponType }); + await this.page.getByLabel(UIReference.cartPriceRulesPage.couponCodeFieldLabel).fill(magentoCouponCode); + + await this.page.getByText(UIReference.cartPriceRulesPage.actionsSubtitleLabel, { exact: true }).click(); + await this.page.getByLabel(UIReference.cartPriceRulesPage.discountAmountFieldLabel).fill('10'); + + const couponSaveButton = this.page.getByRole('button', { name: UIReference.cartPriceRulesPage.saveRuleButtonLabel, exact: true }); + await couponSaveButton.scrollIntoViewIfNeeded(); + await couponSaveButton.click({force:true}); + await expect(this.page.locator( + UIReference.general.messageLocator).filter({hasText: outcomeMarker.magentoAdmin.couponRuleSavedText} + ), "Message 'you saved the rule' is visible").toBeVisible(); + resultMessage = `Coupon code ${magentoCouponCode} has been set and activated.`; + } + + // Clear the search field + await couponSearchField.waitFor(); + const clearSearchButton = this.page.getByRole('button', { name: UIReference.cartPriceRulesPage.clearSearchButtonLabel }); + await clearSearchButton.click(); + await expect(couponSearchField, `Coupon Code search field is empty`).toBeEmpty(); + + return resultMessage; + }; +} + +export default AdminMarketing; \ No newline at end of file diff --git a/dev/tests/e2e/base-tests/poms/admin/orders.page.ts b/dev/tests/e2e/base-tests/poms/admin/orders.page.ts new file mode 100644 index 00000000000..0b2ebb2d41b --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/admin/orders.page.ts @@ -0,0 +1,58 @@ +// @ts-check + +import { expect, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +class AdminOrders { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * @feature Navigate to Admin orders page + * @scenario User navigates to the admin orders page + * @given + * @when I navigate to the orders page + * @then I should see the orders list + * @and I should see the saved order number id + */ + async checkIfOrderExists(orderNumber: string){ + const mainMenuSalesButton = this.page.getByRole('link', { name: UIReference.adminPage.navigation.salesButtonLabel }); + const ordersButtonLink = this.page.getByRole('link', { name: UIReference.adminPage.subNavigation.ordersButtonLabel }).first(); + + await expect(async () => { + await mainMenuSalesButton.click(); + await expect(ordersButtonLink).toBeVisible(); + }).toPass(); + + await ordersButtonLink.click(); + + const ordersSearchField = this.page.getByRole('textbox', {name: UIReference.adminGeneral.tableSearchFieldLabel}); + + // Wait for URL. If loading symbol is visible, wait for it to go away + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/sales/order/index/**`); + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + await ordersSearchField.waitFor(); + await ordersSearchField.fill(orderNumber); + await this.page.getByRole('button', {name: UIReference.adminGeneral.searchButtonLabel}).click(); + + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Loop to ensure the 'results found' text is visible + await expect(async() =>{ + await this.page.getByText(outcomeMarker.adminGeneral.searchResultsFoundText).first(); + }).toPass(); + + await expect(this.page.getByRole('cell', {name:orderNumber}).locator('div')).toBeVisible(); + } +} + +export default AdminOrders; \ No newline at end of file diff --git a/dev/tests/e2e/base-tests/poms/adminhtml/customers.page.ts b/dev/tests/e2e/base-tests/poms/adminhtml/customers.page.ts new file mode 100644 index 00000000000..295e7fee36b --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/adminhtml/customers.page.ts @@ -0,0 +1,188 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, inputValues, outcomeMarker } from '@config'; +import { requireEnv } from "@utils/env.utils"; + +class AdminCustomers { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * @feature Customer Management + * @scenario Check if a customer exists by email address + * @given the admin is on the Magento dashboard + * @when the admin navigates to Customers > All Customers + * @and the customer table is fully loaded + * @and the admin searches for a specific email address + * @then reset the table filter + * @then the system returns whether a customer with that email exists in the customer list + */ + async checkIfCustomerExists(email: string){ + const mainMenuCustomersButton = this.page.getByRole('link', {name: UIReference.adminPage.navigation.customersButtonLabel}).first(); + const allCustomersLink = this.page.getByRole('link', {name: UIReference.adminPage.subNavigation.allCustomersButtonLabel}); + const customersSearchField = this.page.getByRole('textbox', {name: UIReference.customerOverviewPage.tableSearchFieldLabel}); + + // loop clicking the 'Customers' button until clicking it show the subnavigation + await expect(async() =>{ + await mainMenuCustomersButton.press('Enter'); + await expect(allCustomersLink, `Link to "All Customers" is visible`).toBeVisible({timeout: 5000}); + }).toPass(); + + await allCustomersLink.click(); + + // Wait for URL. If loading symbol is visible, wait for it to go away + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/**`); + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + await customersSearchField.waitFor(); + await customersSearchField.fill(email); + await this.page.getByRole('button', {name: UIReference.general.searchButtonLabel}).click(); + + // Wait for the loader spinner to be hidden + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Loop to ensure the 'results found' text is visible + // await expect( + // this.page.getByText(outcomeMarker.adminGeneral.activeFiltersText).first(), + // "There are active filters." + // ).toBeVisible(); + + // Return true (email found) or false (email not found) + const emailIsFound = await this.page.getByRole('cell', {name:email}).locator('div').isVisible(); + + // Click 'Clear all' button on filtered table to reset the table state. + await this.page.getByRole('button', {name: UIReference.adminGeneral.tableFilterResetLabel}).click(); + + // Wait for the loader spinner to be hidden + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + await expect( + this.page.getByText(outcomeMarker.adminGeneral.activeFiltersText).first(), + "There are no filters active." + ).toBeHidden(); + + return emailIsFound; + } + + /** + * @feature Customer Management + * @scenario Create a new customer account + * @given the admin is on the Magento dashboard + * @when the admin navigates to Customers > All Customers + * @and clicks the 'Create New Customer' button + * @then the admin fills in the mandatory fields and optional fields for a new customer account + * @and the system saves the customer account and navigates to the account edit page + * @and displays a confirmation message that the customer was saved + */ + async createNewCustomerAccount( + firstName: string, + lastName: string, + email: string + ) { + const createNewCustomersLink = this.page.getByRole('button', {name: UIReference.adminCustomers.createNewCustomerButtonLabel}); + await createNewCustomersLink.click(); + + // Wait for URL. If loading symbol is visible, wait for it to go away + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/new/**`); + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + const accountCreationFirstNameField = this.page.getByLabel(UIReference.personalInformation.firstNameLabel); + const accountCreationLastNameField = this.page.getByLabel(UIReference.personalInformation.lastNameLabel); + const accountCreationEmailField = this.page.getByLabel(UIReference.credentials.emailFieldLabel, { exact: true}); + const accountCreationConfirmButton = this.page.getByRole('button', {name: UIReference.adminCustomers.registration.createAccountSaveAndContinueButtonLabel}); + const customersSearchField = this.page.getByRole('textbox', {name: UIReference.adminGeneral.tableSearchFieldLabel}); + + // Optional fields: + const allowBulkPurchaseSwitcher = this.page.locator(UIReference.cartPriceRulesPage.activeStatusSwitcherLocator).first(); + + await accountCreationFirstNameField.fill(firstName); + await accountCreationLastNameField.fill(lastName); + await accountCreationEmailField.fill(email); + await allowBulkPurchaseSwitcher.click(); + await accountCreationConfirmButton.click(); + + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/edit/**`); + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + + await expect( + this.page.locator(UIReference.general.messageLocator).filter({hasText: 'You saved the customer.'}) + ).toBeVisible(); + } + + await this.approveAccount(email); + } + + /** + * @feature Customer Management + * @scenario Approve a customer account + * @given the admin is on the Magento dashboard + * @when the admin navigates to Customers > All Customers + * @and searches for a specific email address + * @then the admin clicks on the 'Edit' link for the corresponding customer + * @and approves the customer account + * @and the system displays a confirmation message that the customer account has been approved + */ + async approveAccount(email: string) { + + const customersSearchField = this.page.getByRole('textbox', {name: UIReference.adminGeneral.tableSearchFieldLabel}); + const editAccountButton = this.page.getByRole('link', {name: 'Edit'}).first() + const approvalButtonAccountEdit = this.page.getByRole('button', {name: 'Approve'}) + + await customersSearchField.waitFor(); + await customersSearchField.fill(email); + await this.page.getByRole('button', {name: UIReference.adminGeneral.searchButtonLabel}).click(); + + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Loop to ensure the 'results found' text is visible + await expect(async() =>{ + await this.page.getByText(outcomeMarker.customerOverviewPage.searchResultsFoundText).first(); + }).toPass(); + + // Return true (email found) or false (email not found) + await this.page.getByRole('cell', {name:email}).locator('div').isVisible(); + + await expect(async() => { + editAccountButton.click(); + }).toPass(); + + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/edit/**`); + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + console.log('Spinner is visible'); + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Press approval button when approval button is visible + if (await approvalButtonAccountEdit.isVisible()) { + await approvalButtonAccountEdit.click(); + + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/edit/**`); + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + console.log('Spinner is visible'); + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + await expect( + this.page + .locator(UIReference.general.messageLocator) + .filter({ hasText: 'Customer account has been approved!' }) + ).toBeVisible(); + } + } +} + +export default AdminCustomers; diff --git a/dev/tests/e2e/base-tests/poms/adminhtml/login.page.ts b/dev/tests/e2e/base-tests/poms/adminhtml/login.page.ts new file mode 100644 index 00000000000..cc95914e762 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/adminhtml/login.page.ts @@ -0,0 +1,184 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, inputValues, outcomeMarker } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +class AdminLogin { + readonly page: Page; + readonly adminLoginEmailField: Locator; + readonly adminLoginPasswordField: Locator; + readonly adminLoginButton: Locator; + + constructor(page: Page) { + this.page = page; + this.adminLoginEmailField = page.locator(UIReference.adminPage.usernameFieldId); + this.adminLoginPasswordField = page.locator(UIReference.adminPage.passwordFieldId); + this.adminLoginButton = page.locator(UIReference.adminPage.loginButtonClass); + } + + /** + * @feature Magento Admin Configuration + * @scenario Disable the login CAPTCHA on the admin panel + * @given the admin is logged into the Magento dashboard + * @when the admin navigates to Stores > Configuration > Customers > Customer Configuration > CAPTCHA section + * @and the "Use system value" checkbox for CAPTCHA is unchecked + * @and the "Enable CAPTCHA on Admin Login" select field is visible + * @and the current setting is "Yes" + * @then the admin changes the setting to "No" + * @and clicks the Save Config button + * @then the system displays a success message confirming the configuration was saved + */ + async disableLoginCaptcha() { + const mainMenuStoresButton = this.page.getByRole('link', { name: UIReference.adminPage.navigation.storesButtonLabel}); + // selecting first specifically because plugins can place another 'configuration' link in this menu. + const storeSettingsConfigurationLink = this.page.getByRole('link', { name: UIReference.adminPage.subNavigation.configurationButtonLabel }).first(); + + await expect(async () => { + await mainMenuStoresButton.click(); + await expect(storeSettingsConfigurationLink, `Link to Store Configuration is visible`).toBeVisible(); + }).toPass(); + + await storeSettingsConfigurationLink.click(); + + const customersTab = this.page.getByRole('tab', { name: UIReference.configurationPage.customersTabLabel }); + const customerConfigurationLink = this.page.getByRole('link', { name: UIReference.configurationPage.customerConfigurationTabLabel }); + await customersTab.click(); + await customerConfigurationLink.waitFor(); + await customerConfigurationLink.click(); + + const captchaSettingsBlock = this.page.getByRole('link', { name: UIReference.configurationPage.captchaSectionLabel }) + .filter({hasNotText: 'documentation'}); + const captchaSettingsSystemValueCheckbox = this.page.locator(UIReference.configurationPage.captchaSettingSystemCheckbox); + + await captchaSettingsBlock.waitFor(); + + if(!await captchaSettingsSystemValueCheckbox.isVisible()) { + await captchaSettingsBlock.click(); + await expect(captchaSettingsSystemValueCheckbox, `Checkbox "Use system value" for CAPTCHA is visible`).toBeVisible(); + } + + if(await captchaSettingsSystemValueCheckbox.isChecked()){ + await captchaSettingsSystemValueCheckbox.uncheck(); + } + + const captchaSettingSelectField = this.page.locator(UIReference.configurationPage.captchaSettingSelectField); + const selectedOption = await captchaSettingSelectField.locator('option:checked').textContent(); + + // We only have to perform these steps if the option is set to 'Yes' + if(selectedOption == 'Yes') { + await captchaSettingSelectField.selectOption({label: inputValues.captcha.captchaDisabled}); + + const saveConfigButton = this.page.getByRole('button', { name: UIReference.configurationPage.saveConfigButtonLabel }); + await saveConfigButton.click(); + + await expect(this.page.locator(UIReference.general.messageLocator).filter( + {hasText: outcomeMarker.magentoAdmin.configurationSavedText}), + `Notification "${outcomeMarker.magentoAdmin.configurationSavedText}" is visible`).toBeVisible(); + } else { + await expect(selectedOption,`CAPTCHA is disabled`) + .toEqual(expect.stringContaining(UIReference.adminPage.captchaDisabledLabel)); + } + } + + /** + * @feature Enable multiple admin logins in Magento + * @scenario Admin enables the ability for multiple users to log in with the same admin account + * @given the user is on the Magento admin dashboard + * @when the user navigates to Stores > Configuration > Advanced > Admin > Security + * @and the "Allow Multiple Admin Account Login" field is visible + * @and the "Use system value" checkbox is unchecked + * @and the select field value is "No" + * @then the user selects "Yes" from the dropdown + * @and clicks the Save Config button + * @then the system displays a success message + */ + async enableMultipleAdminLogins() { + const mainMenuStoresButton = this.page.getByRole('link', { name: UIReference.adminPage.navigation.storesButtonLabel}); + // selecting first specifically because plugins can place another 'configuration' link in this menu. + const storeSettingsConfigurationLink = this.page.getByRole('link', { name: UIReference.adminPage.subNavigation.configurationButtonLabel }).first(); + + await expect(async () => { + await mainMenuStoresButton.click(); + await expect(storeSettingsConfigurationLink, `Link to Store Configuration is visible`).toBeVisible(); + }).toPass(); + + await storeSettingsConfigurationLink.click(); + + const advancedConfigurationTab = this.page.getByRole('tab', { name: UIReference.configurationPage.advancedTabLabel }); + const advancedConfigAdminLabel = this.page.getByRole('link', { name: UIReference.configurationPage.advancedAdministrationTabLabel, exact: true }); + await advancedConfigurationTab.click(); + await advancedConfigAdminLabel.waitFor(); + await advancedConfigAdminLabel.click(); + + const advancedConfigSecuritySection = this.page.getByRole('link', { name: UIReference.configurationPage.securitySectionLabel }); + const multipleLoginsSystemCheckbox = this.page.locator(UIReference.configurationPage.allowMultipleLoginsSystemCheckbox); + + await advancedConfigSecuritySection.waitFor(); + if (!await multipleLoginsSystemCheckbox.isVisible()) { + await advancedConfigSecuritySection.click(); + } + + await expect(multipleLoginsSystemCheckbox, `Checkbox for multiple admin logins is visible`).toBeVisible(); + + // make sure the 'use system value' option is not checked + const adminAccountSharingSystemValueCheckbox = this.page.locator(UIReference.configurationPage.allowMultipleLoginsSystemCheckbox); + if (await adminAccountSharingSystemValueCheckbox.isChecked()) { + await adminAccountSharingSystemValueCheckbox.uncheck(); + } + + const allowMultipleLoginSelectField = this.page.locator(UIReference.configurationPage.allowMultipleLoginsSelectField); + const selectedOption = await allowMultipleLoginSelectField.locator('option:checked').textContent(); + + // We only have to perform these steps if the option is set to 'No' + if(selectedOption == 'No') { + await allowMultipleLoginSelectField.selectOption({label: inputValues.adminLogins.allowMultipleLogins}); + + const saveConfigButton = this.page.getByRole('button', { name: UIReference.configurationPage.saveConfigButtonLabel }); + await saveConfigButton.click(); + + await expect(this.page.locator(UIReference.general.messageLocator).filter( + {hasText: outcomeMarker.magentoAdmin.configurationSavedText}), + `Notification "${outcomeMarker.magentoAdmin.configurationSavedText}" is visible`).toBeVisible(); + } + } + + /** + * @feature Login to Magento admin dashboard + * @scenario User logs in to admin dashboard + * @given the admin slug environment variable is defined + * @and the user navigates to the admin login page + * @when the user enters a valid username and password + * @and the user clicks the login button + * @then the user should see the dashboard heading displayed + */ + async login(username: string, password: string){ + await this.page.goto(requireEnv('MAGENTO_ADMIN_SLUG')); + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}`); + + if(await this.page.getByRole('heading', {name: UIReference.adminPage.dashboardHeadingText}).isVisible()) { + // already logged in + return; + } + + await this.adminLoginEmailField.fill(username); + await this.adminLoginPasswordField.fill(password); + await this.adminLoginButton.click(); + + const captchaNotification = this.page.locator(UIReference.general.messageLocator).filter({hasText: UIReference.adminPage.captchaIncorrectText}); + + if(await captchaNotification.isVisible()) { + console.log('CAPTCHA field is visible, automated login not possible!'); + throw new Error("CAPTCHA field is visible, automated login not possible!"); + } + + const dashboardLabel = this.page.getByRole('heading',{level:1, name: UIReference.adminPage.dashboardHeadingText}); + + // expect the H1 'Dashboard' to be visible + await expect(async () => { + await expect(dashboardLabel, `Title "Dashboard" is visible`).toBeVisible(); + }).toPass(); + } +} + +export default AdminLogin; diff --git a/dev/tests/e2e/base-tests/poms/adminhtml/marketing.page.ts b/dev/tests/e2e/base-tests/poms/adminhtml/marketing.page.ts new file mode 100644 index 00000000000..452a38abfc7 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/adminhtml/marketing.page.ts @@ -0,0 +1,128 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import {UIReference, inputValues, outcomeMarker} from '@config'; + +class AdminMarketing { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * @feature Cart Price Rules Configuration + * @scenario Add or activate a cart price rule with a specific coupon code + * @given the admin is on the Magento dashboard + * @when the coupon exists but is inactive + * @then the admin activates the existing coupon and saves the rule + * @but if the coupon does not exist + * @then the admin creates a new cart price rule with the given coupon code + * @and selects all websites and customer groups + * @and sets the coupon type and discount amount + * @and clicks the Save button + * @then the system displays a success message confirming the rule was saved + */ + async addCartPriceRule(magentoCouponCode: string){ + let resultMessage = ""; + + // Force specific viewport size to deal with webkit issues + await this.page.setViewportSize({ + width: 1920, + height: 1080 + }) + + const mainMenuMarketingButton = this.page.getByRole('link', {name: UIReference.adminPage.navigation.marketingButtonLabel}); + const cartPriceRulesLink = this.page.getByRole('link', {name: UIReference.adminPage.subNavigation.cartPriceRulesButtonLabel}); + await expect(mainMenuMarketingButton, `Button for Marketing is visible`).toBeVisible(); + + await expect(async () => { + await mainMenuMarketingButton.click(); + await expect(cartPriceRulesLink, `Button for Cart Price Rules is visible`).toBeVisible(); + }).toPass(); + + await cartPriceRulesLink.click(); + + const addCartPriceRuleButton = this.page.getByRole('button', {name: UIReference.cartPriceRulesPage.addCartPriceRuleButtonLabel}); + await addCartPriceRuleButton.waitFor(); + + // Use search field to check for coupon codes. + const couponSearchField = this.page.locator(UIReference.adminPage.couponSearchFieldLocator); + await couponSearchField.fill(magentoCouponCode); + await this.page.getByRole('button', {name: UIReference.general.searchButtonLabel}).click(); + + await expect(this.page.getByText(UIReference.adminPage.searchResultsText), `Search results text visible`).toBeVisible(); + + const couponCellField = this.page.getByRole('cell', { name: outcomeMarker.magentoAdmin.noResultsFoundText }); + + if(await couponCellField.isHidden()){ + const couponStatusField = this.page.locator('tr').filter({hasText:magentoCouponCode}).first(); + const couponStatus = await couponStatusField.innerText(); + if(couponStatus.includes(UIReference.cartPriceRulesPage.couponCodeActiveStatusText)){ + resultMessage = 'Coupon already exists and is active.'; + } else { + // coupon has been found, but is not active. + await couponStatusField.click(); + const activeStatusSwitcher = this.page.locator(UIReference.cartPriceRulesPage.activeStatusSwitcherLocator).first(); + const activeStatusLabel = this.page.locator(UIReference.cartPriceRulesPage.activeStatusLabelLocator).first(); + + await expect(activeStatusLabel, `Active/Disable toggle is visible`).toBeVisible(); + await activeStatusSwitcher.click(); + + const saveCouponButton = this.page.getByRole('button', {name:UIReference.cartPriceRulesPage.saveRuleButtonLabel, exact:true}); + await saveCouponButton.click(); + + await expect(this.page.locator( + UIReference.general.messageLocator).filter({hasText: outcomeMarker.magentoAdmin.couponRuleSavedText} + ), "Message 'you saved the rule' is visible").toBeVisible(); + resultMessage = `Coupon code ${magentoCouponCode} has been activated.`; + } + } else { + // coupon is not set + await addCartPriceRuleButton.click(); + + const websiteSelector = this.page.getByLabel(UIReference.cartPriceRulesPage.websitesSelectLabel); + await websiteSelector.evaluate(select => { + const s = select as HTMLSelectElement; + for (const option of s.options) { + option.selected = true; + } + select.dispatchEvent(new Event('change')); + }); + + const customerGroupsSelector = this.page.getByLabel(UIReference.cartPriceRulesPage.customerGroupsSelectLabel, { exact: true }); + await customerGroupsSelector.evaluate(select => { + const s = select as HTMLSelectElement; + for (const option of s.options) { + option.selected = true; + } + select.dispatchEvent(new Event('change')); + }); + + await this.page.getByRole('textbox', { name: UIReference.cartPriceRulesPage.ruleNameFieldLabel }).fill(magentoCouponCode); + await this.page.locator(UIReference.cartPriceRulesPage.couponTypeSelectField).selectOption({ label: inputValues.coupon.couponType }); + await this.page.getByLabel(UIReference.cartPriceRulesPage.couponCodeFieldLabel).fill(magentoCouponCode); + + await this.page.getByText(UIReference.cartPriceRulesPage.actionsSubtitleLabel, { exact: true }).click(); + await this.page.getByLabel(UIReference.cartPriceRulesPage.discountAmountFieldLabel).fill('10'); + + const couponSaveButton = this.page.getByRole('button', { name: UIReference.cartPriceRulesPage.saveRuleButtonLabel, exact: true }); + await couponSaveButton.scrollIntoViewIfNeeded(); + await couponSaveButton.click({force:true}); + await expect(this.page.locator( + UIReference.general.messageLocator).filter({hasText: outcomeMarker.magentoAdmin.couponRuleSavedText} + ), "Message 'you saved the rule' is visible").toBeVisible(); + resultMessage = `Coupon code ${magentoCouponCode} has been set and activated.`; + } + + // Clear the search field + await couponSearchField.waitFor(); + const clearSearchButton = this.page.getByRole('button', { name: UIReference.cartPriceRulesPage.clearSearchButtonLabel }); + await clearSearchButton.click(); + await expect(couponSearchField, `Coupon Code search field is empty`).toBeEmpty(); + + return resultMessage; + }; +} + +export default AdminMarketing; \ No newline at end of file diff --git a/dev/tests/e2e/base-tests/poms/adminhtml/orders.page.ts b/dev/tests/e2e/base-tests/poms/adminhtml/orders.page.ts new file mode 100644 index 00000000000..0b2ebb2d41b --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/adminhtml/orders.page.ts @@ -0,0 +1,58 @@ +// @ts-check + +import { expect, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +class AdminOrders { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * @feature Navigate to Admin orders page + * @scenario User navigates to the admin orders page + * @given + * @when I navigate to the orders page + * @then I should see the orders list + * @and I should see the saved order number id + */ + async checkIfOrderExists(orderNumber: string){ + const mainMenuSalesButton = this.page.getByRole('link', { name: UIReference.adminPage.navigation.salesButtonLabel }); + const ordersButtonLink = this.page.getByRole('link', { name: UIReference.adminPage.subNavigation.ordersButtonLabel }).first(); + + await expect(async () => { + await mainMenuSalesButton.click(); + await expect(ordersButtonLink).toBeVisible(); + }).toPass(); + + await ordersButtonLink.click(); + + const ordersSearchField = this.page.getByRole('textbox', {name: UIReference.adminGeneral.tableSearchFieldLabel}); + + // Wait for URL. If loading symbol is visible, wait for it to go away + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/sales/order/index/**`); + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + await ordersSearchField.waitFor(); + await ordersSearchField.fill(orderNumber); + await this.page.getByRole('button', {name: UIReference.adminGeneral.searchButtonLabel}).click(); + + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Loop to ensure the 'results found' text is visible + await expect(async() =>{ + await this.page.getByText(outcomeMarker.adminGeneral.searchResultsFoundText).first(); + }).toPass(); + + await expect(this.page.getByRole('cell', {name:orderNumber}).locator('div')).toBeVisible(); + } +} + +export default AdminOrders; \ No newline at end of file diff --git a/dev/tests/e2e/base-tests/poms/frontend/account.page.ts b/dev/tests/e2e/base-tests/poms/frontend/account.page.ts new file mode 100644 index 00000000000..b2329ddd6f1 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/account.page.ts @@ -0,0 +1,298 @@ +// @ts-check + +import {expect, type Locator, type Page, test, TestInfo} from '@playwright/test'; +import { faker } from '@faker-js/faker'; +import { UIReference, outcomeMarker, inputValues, slugs } from '@config'; + +import LoginPage from '@poms/frontend/login.page'; + +class AccountPage { + readonly page: Page; + readonly accountDashboardTitle: Locator; + readonly firstNameField: Locator; + readonly lastNameField: Locator; + readonly companyNameField: Locator; + readonly phoneNumberField: Locator; + readonly loginPage: LoginPage; + readonly streetAddressField: Locator; + readonly zipCodeField: Locator; + readonly cityField: Locator; + readonly countrySelectorField: Locator; + readonly stateSelectorField: Locator; + readonly stateInputField: Locator; + readonly saveAddressButton: Locator; + readonly addNewAddressButton: Locator; + readonly deleteAddressButton: Locator; + readonly editAddressButton: Locator; + readonly changePasswordSwitch: Locator; + readonly changeEmailCheck: Locator; + readonly currentPasswordField: Locator; + readonly newPasswordField: Locator; + readonly confirmNewPasswordField: Locator; + readonly genericSaveButton: Locator; + readonly accountCreationFirstNameField: Locator; + readonly accountCreationLastNameField: Locator; + readonly accountCreationEmailField: Locator; + readonly accountCreationPasswordField: Locator; + readonly accountCreationPasswordRepeatField: Locator; + readonly accountCreationConfirmButton: Locator; + readonly accountInformationField: Locator; + + constructor(page: Page) { + this.page = page; + this.loginPage = new LoginPage(page); + + this.accountDashboardTitle = page.getByRole('heading', { name: UIReference.accountDashboard.accountDashboardTitleLabel }); + this.firstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel); + this.lastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel); + // this.companyNameField = page.getByLabel(UIReference.newAddress.companyNameLabel); + this.companyNameField = page.getByRole('textbox', {name: UIReference.newAddress.companyNameLabel}); + this.phoneNumberField = page.getByLabel(UIReference.newAddress.phoneNumberLabel); + this.streetAddressField = page.getByLabel(UIReference.newAddress.streetAddressLabel, { exact: true }); + this.zipCodeField = page.getByLabel(UIReference.newAddress.zipCodeLabel); + this.cityField = page.getByLabel(UIReference.newAddress.cityNameLabel); + this.countrySelectorField = page.getByLabel(UIReference.newAddress.countryLabel); + + this.stateInputField = page.getByLabel(UIReference.newAddress.provinceSelectLabel); + this.stateSelectorField = this.stateInputField.filter({ hasText: UIReference.newAddress.provinceSelectFilterLabel }); + + this.saveAddressButton = page.getByRole('button', { name: UIReference.newAddress.saveAdressButton }); + + // Account Information elements + this.changePasswordSwitch = page.getByRole('switch', { name: UIReference.personalInformation.changePasswordSwitchLabel }); + this.changeEmailCheck = page.getByRole('switch', { name: UIReference.personalInformation.changeEmailCheckLabel }); + this.currentPasswordField = page.getByLabel(UIReference.credentials.currentPasswordFieldLabel); + this.newPasswordField = page.getByLabel(UIReference.credentials.newPasswordFieldLabel, { exact: true }); + this.confirmNewPasswordField = page.getByLabel(UIReference.credentials.newPasswordConfirmFieldLabel); + this.genericSaveButton = page.getByRole('button', { name: UIReference.general.genericSaveButtonLabel }); + + // Account Creation elements + this.accountCreationFirstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel); + this.accountCreationLastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel); + this.accountCreationEmailField = page.getByLabel(UIReference.credentials.emailFieldLabel, { exact: true }); + this.accountCreationPasswordField = page.getByLabel(UIReference.credentials.passwordFieldLabel, { exact: true }); + this.accountCreationPasswordRepeatField = page.getByLabel(UIReference.credentials.passwordConfirmFieldLabel); + this.accountCreationConfirmButton = page.getByRole('button', { name: UIReference.accountCreation.createAccountButtonLabel }); + + this.accountInformationField = page.locator(UIReference.accountDashboard.accountInformationFieldLocator).first(); + + // Address Book elements + this.addNewAddressButton = page.getByRole('button', { name: UIReference.accountDashboard.addAddressButtonLabel }); + this.deleteAddressButton = page.getByRole('link', { name: UIReference.accountDashboard.addressDeleteIconButton }).first(); + this.editAddressButton = page.getByRole('link', { name: UIReference.accountDashboard.editAddressIconButton }).first(); + } + + /** + * Add an address to test account + * @param values - Optional values to fill the form with + */ + async addNewAddress(values?: { + company?: string; + phone?: string; + street?: string; + zip?: string; + city?: string; + state?: string; + country?: string; + }) { + let addressAddedNotification = outcomeMarker.address.newAddressAddedNotifcation; + + await expect(this.firstNameField, `first name should be pre-filled`).not.toBeEmpty(); + await expect(this.lastNameField, `last name should be pre-filled`).not.toBeEmpty(); + + const phone = values?.phone || faker.phone.number({style: 'national'}); // Use 'national' style to prevent input errors + const streetName = values?.street || faker.location.streetAddress(); + const zipCode = values?.zip || faker.location.zipCode(); + const cityName = values?.city || faker.location.city(); + const stateName = values?.state || faker.location.state(); + const country = values?.country || faker.helpers.arrayElement(inputValues.addressCountries); + if (values?.company) { + await this.companyNameField.fill(values.company); + } + + await this.phoneNumberField.fill(phone); + await this.streetAddressField.fill(streetName); + await this.zipCodeField.fill(zipCode); + await this.cityField.fill(cityName); + + // If default selected country == country we want to use for the test, + // don't re-select it. + const defaultSelectedCountry = await this.countrySelectorField.evaluate( + (select: HTMLSelectElement) => select.options[select.selectedIndex]?.text + ); + + if(country !== defaultSelectedCountry) { + await this.countrySelectorField.selectOption({label: country}); + } + const regionDropdown = this.page.locator(UIReference.newAddress.regionDropdownLocator); + const regionInputField = this.page.getByRole('textbox', {name: UIReference.newAddress.provinceSelectLabel}); + + if(country !== 'United States') { + await expect(regionDropdown, `Dropdown should not be visible`).toBeHidden(); + await expect(regionInputField, `Region input field should be visible`).toBeVisible(); + + await regionInputField.fill(stateName); + } else { + await expect(regionInputField, `Dropdown should not be visible`).toBeHidden(); + await expect(regionDropdown, `State input field should be editable`).toBeEditable(); + // await regionDropdown.selectOption(stateName); + await this.stateSelectorField.selectOption(stateName); + // Timeout because Alpine uses an @input.debounce to delay the activation of the event + // Standard debounce is 250ms. + await this.page.waitForTimeout(1000); + } + + await this.saveAddressButton.scrollIntoViewIfNeeded(); + await this.saveAddressButton.click(); + await this.page.waitForLoadState(); + + await expect.soft(this.page.getByText(addressAddedNotification), `message that confirms actions should be visible`).toBeVisible(); + } + + + + async editExistingAddress(values?: { + firstName?: string; + lastName?: string; + company?: string; + phone?: string; + street?: string; + zip?: string; + city?: string; + state?: string; + country?: string; + }, defaultAddress: boolean = false) { + let addressModifiedNotification = outcomeMarker.address.newAddressAddedNotifcation; + + const firstName = values?.firstName || faker.person.firstName(); + const lastName = values?.lastName || faker.person.lastName(); + const companyName = values?.company || faker.company.name(); + const phone = values?.phone || faker.phone.number({style: 'national'}); // Use 'national' style to prevent input errors + const streetName = values?.street || faker.location.streetAddress(); + const zipCode = values?.zip || faker.location.zipCode(); + const cityName = values?.city || faker.location.city(); + const stateName = values?.state || faker.location.state(); + const country = values?.country || faker.helpers.arrayElement(inputValues.addressCountries); + + // click the correct button based on if there's more than one address (defaultAddress boolean) + defaultAddress ? await this.page.getByRole('link', { name: 'Change Shipping Address arrow' }).click() : await this.editAddressButton.click(); + + let oldAddress = await this.streetAddressField.inputValue(); + + await expect(this.firstNameField,`first name field should be filled in automatically`).not.toBeEmpty(); + await expect(this.lastNameField, `first name field should be filled in automatically`).not.toBeEmpty(); + + // contact information section + await this.firstNameField.fill(firstName); + await this.lastNameField.fill(lastName); + await this.companyNameField.fill(companyName); + await this.phoneNumberField.fill(phone); + + // Address information section + await this.streetAddressField.fill(streetName); + await this.zipCodeField.fill(zipCode); + await this.cityField.fill(cityName); + + // If default selected country == country we want to use for the test, + // don't re-select it. + const defaultSelectedCountry = await this.countrySelectorField.evaluate( (select: HTMLSelectElement) => select.options[select.selectedIndex]?.text); + if(country !== defaultSelectedCountry) { + await this.countrySelectorField.selectOption({label: country}); + } + + const regionDropdown = this.page.locator(UIReference.newAddress.regionDropdownLocator); + const regionInputField = this.page.getByRole('textbox', {name: UIReference.newAddress.provinceSelectLabel}); + + if(country !== 'United States') { + await expect(regionDropdown, `Dropdown should not be visible`).toBeHidden(); + await expect(regionInputField, `Region input field should be visible`).toBeVisible(); + + await regionInputField.fill(stateName); + } else { + // await regionDropdown.selectOption(stateName); + await this.stateSelectorField.selectOption(stateName); + // Timeout because Alpine uses an @input.debounce to delay the activation of the event + // Standard debounce is 250ms. + await this.page.waitForTimeout(1000); + } + + await this.saveAddressButton.scrollIntoViewIfNeeded(); + await this.saveAddressButton.click(); + await this.page.waitForLoadState(); + + await expect.soft(this.page.getByText(addressModifiedNotification)).toBeVisible(); + // await expect(this.page.getByText(streetName).last()).toBeVisible(); + if (oldAddress != null) await expect(this.page.getByText(oldAddress)).not.toBeVisible(); + } + + async deleteFirstAddressFromAddressBook() { + let addressDeletedNotification = outcomeMarker.address.addressDeletedNotification; + let addressBookSection = this.page.locator(UIReference.accountDashboard.addressBookArea); + + this.page.on('dialog', async (dialog) => { + if (dialog.type() === 'confirm') { + await dialog.accept(); + } + }); + + // Retrieve all text in the 'address book' section + let addressBookArray = await addressBookSection.allInnerTexts(); + // split by each new line + let arraySplit = addressBookArray[0].split('\n'); + // Retrieve index 8, because: + // index 0 to 5 are the table headers (i.e. Company, Name etc.) + // index 6 is company, index 7 is name, and index 8 is the first address value. + // if this table changes, the index number should change. + let addressToBeDeleted = arraySplit[8]; + + // Annotate the report so the user knows what address should be deleted + test.info().annotations.push({type: `Address to be deleted`, description: addressToBeDeleted}); + + await this.deleteAddressButton.click(); + await this.page.waitForLoadState(); + + await expect(this.page.getByText(addressDeletedNotification)).toBeVisible(); + await expect(addressBookSection, `${addressToBeDeleted} should not be visible`).not.toContainText(addressToBeDeleted); + } + + async updatePassword(currentPassword: string, newPassword: string) { + let passwordUpdatedNotification = outcomeMarker.account.changedCredentialsInformation; + await this.changePasswordSwitch.check(); + await this.currentPasswordField.fill(currentPassword); + await this.newPasswordField.fill(newPassword); + await this.confirmNewPasswordField.fill(newPassword); + await this.genericSaveButton.click(); + + await this.page.waitForURL(new RegExp(slugs.account.loginSlug)); + await expect(this.page.getByText(passwordUpdatedNotification)).toBeVisible(); + } + + async updateEmail(currentPassword: string, newEmail: string) { + let accountUpdatedNotification = outcomeMarker.account.changedCredentialsInformation; + await this.changeEmailCheck.check(); + await this.accountCreationEmailField.fill(newEmail); + await this.currentPasswordField.fill(currentPassword); + await this.genericSaveButton.click(); + + await this.page.waitForURL(new RegExp(slugs.account.loginSlug)); + await expect(this.page.getByText(accountUpdatedNotification)).toBeVisible(); + } + + async deleteAllAddresses() { + let addressDeletedNotification = outcomeMarker.address.addressDeletedNotification; + + this.page.on('dialog', async (dialog) => { + if (dialog.type() === 'confirm') { + await dialog.accept(); + } + }); + + while (await this.deleteAddressButton.isVisible()) { + await this.deleteAddressButton.click(); + await this.page.waitForLoadState(); + await expect.soft(this.page.getByText(addressDeletedNotification)).toBeVisible(); + } + } +} + +export default AccountPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/category.page.ts b/dev/tests/e2e/base-tests/poms/frontend/category.page.ts new file mode 100644 index 00000000000..f2fc3d0fd19 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/category.page.ts @@ -0,0 +1,139 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, slugs } from '@config'; + +class CategoryPage { + readonly page:Page; + categoryPageTitle: Locator; + + constructor(page: Page) { + this.page = page; + this.categoryPageTitle = this.page.getByRole('heading', { name: UIReference.categoryPage.categoryPageTitleText }); + } + + /** + * @feature Navigate to Category page + * @scenario User navigates to the category page + * @given + * @when I navigate to the category page + * @then I should see the filter options + * @and I should see the title of the page + */ + async goToCategoryPage(){ + await this.page.goto(slugs.categoryPage.categorySlug); + + this.page.waitForLoadState(); + await expect(this.categoryPageTitle).toBeVisible(); + } + + /** + * @feature Filter category page + * @scenario User filters category page on size XS + * @given I am on the category page + * @when I open the Size filter category + * @and I click the size XS button + * @then the URL should reflect this filter + * @and I should see the active filtering button and Clear All link + */ + async filterOnSize() { + const filterRegion = this.page.getByRole('region', {name: 'Product filters'}); + const sizeFilterButton = filterRegion.getByRole('button', {name: UIReference.categoryPage.sizeFilterButtonLabel}); + const sizeXSButton = filterRegion.getByRole('link', {name: UIReference.categoryPage.sizeXSLinkLabel}); + const activeFilteringButton = this.page.getByRole('button', {name: UIReference.categoryPage.activeFilterButtonLabel}); + const clearAllLink = this.page.getByRole('link', {name: UIReference.categoryPage.clearAllFiltersLinkLabel}); + + // Scroll to the size filter to trigger Alpine.js deferred initialization + await sizeFilterButton.scrollIntoViewIfNeeded(); + + // Check if the size filter is already opened, if not open it + await expect(async () => { + const isExpanded = await sizeFilterButton.getAttribute('aria-expanded'); + if (isExpanded !== 'true') { + await sizeFilterButton.click(); + } + await expect(sizeXSButton).toBeVisible(); + }).toPass(); + + // Click on the XS filter option + await sizeXSButton.click(); + await this.page.waitForURL(/\?size=166/); + + // Verify active filtering is shown and Clear All link is available + await expect(activeFilteringButton, 'Active filtering button should be visible').toBeVisible(); + await expect(clearAllLink, 'Clear All link should be visible').toBeVisible(); + } + + /** + * @feature Sort category page by price + * @scenario User sorts category page by price + * @given I am on the category page + * @when I open the 'Sort' dropdown + * @and I click the price button + * @then the URL should reflect this filter + * @and I should see products sorted by price + */ + async sortProducts(attribute:string){ + const sortButton = this.page.getByLabel(UIReference.categoryPage.sortByButtonLabel); + await sortButton.selectOption(attribute); + const sortRegex = new RegExp(`\\?product_list_order=${attribute}$`); + await this.page.waitForURL(sortRegex); + + const selectedValue = await this.page.$eval(UIReference.categoryPage.sortByButtonLocator, sel => (sel as HTMLSelectElement).value); + + // sortButton should now display attribute + expect(selectedValue, `Sort button should now display ${attribute}`).toEqual(attribute); + // URL now has ?product_list_order=${attribute} + expect(this.page.url(), `URL should contain ?product_list_order=${attribute}`).toContain(`product_list_order=${attribute}`); + } + + /** + * @feature products per page + * @scenario User updates the amount of products shown on the page + * @given I am on the category page + * @when I change the 'Show' dropdown + * @then the URl should reflect this filter + * @and the amount of items should be the new amount I've selected + */ + async showMoreProducts(){ + const itemsPerPageButton = this.page.getByLabel(UIReference.categoryPage.itemsPerPageButtonLabel); + const productGrid = this.page.locator(UIReference.categoryPage.productGridLocator); + + await itemsPerPageButton.selectOption('36'); + const itemsRegex = /\?product_list_limit=36$/; + await this.page.waitForURL(itemsRegex); + + const amountOfItems = await productGrid.locator('li').count(); + + expect(this.page.url(), `URL should contain ?product_list_limit=36`).toContain(`?product_list_limit=36`); + expect(amountOfItems, `Amount of items on the page should be 36`).toBe(36); + } + + /** + * @feature View switcher + * @scenario User switches from the grid to the list view + * @given I am on the category page + * @when I click the grid or list mode button + * @then the URl should reflect this updated view + * @and the reported selected view should not be the same as it was before I clicked the button + */ + async switchView(){ + const viewSwitcher = this.page.getByLabel(UIReference.categoryPage.viewSwitchLabel, {exact: true}).locator(UIReference.categoryPage.activeViewLocator); + const activeView = await viewSwitcher.getAttribute('title'); + + if(activeView == 'Grid'){ + await this.page.getByLabel(UIReference.categoryPage.viewListLabel).click(); + } else { + await this.page.getByLabel(UIReference.categoryPage.viewGridLabel).click(); + } + + const viewRegex = /\?product_list_mode=list$/; + await this.page.waitForURL(viewRegex); + + const newActiveView = await viewSwitcher.getAttribute('title'); + expect(newActiveView, `View (now ${newActiveView}) should be switched (old: ${activeView})`).not.toEqual(activeView); + expect(this.page.url(),`URL should contain ?product_list_mode=${newActiveView?.toLowerCase()}`).toContain(`?product_list_mode=${newActiveView?.toLowerCase()}`); + } +} + +export default CategoryPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/checkout.page.ts b/dev/tests/e2e/base-tests/poms/frontend/checkout.page.ts new file mode 100644 index 00000000000..864bfac6759 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/checkout.page.ts @@ -0,0 +1,278 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { faker } from '@faker-js/faker'; +import { UIReference, outcomeMarker, slugs, inputValues } from '@config'; +import MagewireUtils from '@utils/magewire.utils'; + +class CheckoutPage extends MagewireUtils { + + readonly shippingMethodOptionFixed: Locator; + readonly shippingMethodTableRateFixed: Locator; + readonly paymentMethodOptionCheck: Locator; + readonly showDiscountFormButton: Locator; + readonly placeOrderButton: Locator; + readonly continueShoppingButton: Locator; + readonly subtotalElement: Locator; + readonly shippingElement: Locator; + readonly taxElement: Locator; + readonly grandTotalElement: Locator; + readonly paymentMethodOptionCreditCard: Locator; + readonly paymentMethodOptionPaypal: Locator; + readonly creditCardNumberField: Locator; + readonly creditCardExpiryField: Locator; + readonly creditCardCVVField: Locator; + readonly creditCardNameField: Locator; + + constructor( + page: Page + ){ + super(page); + this.shippingMethodOptionFixed = this.page.getByLabel(UIReference.checkout.shippingMethodFixedLabel); + this.shippingMethodTableRateFixed = this.page.getByLabel(UIReference.checkout.shippingMethodTableRateLabel); + this.paymentMethodOptionCheck = this.page.getByRole('radio', {name: UIReference.checkout.paymentOptionCheckLabel}); + this.showDiscountFormButton = this.page.getByRole('button', {name: UIReference.checkout.openDiscountFormLabel}); + this.placeOrderButton = this.page.getByRole('button', { name: UIReference.checkout.placeOrderButtonLabel }); + this.continueShoppingButton = this.page.getByRole('link', { name: UIReference.checkout.continueShoppingLabel }); + // this.subtotalElement = page.getByText('Subtotal $'); + this.subtotalElement = page.getByText(`${UIReference.financial.subTotal} ${UIReference.general.genericPriceSymbol}`); + // this.shippingElement = page.getByText('Shipping & Handling (Flat Rate - Fixed) $'); + this.shippingElement = page.getByText(`${UIReference.checkout.shippingPriceText} ${UIReference.general.genericPriceSymbol}`); + // this.taxElement = page.getByText('Tax $'); + this.taxElement = page.getByText(`${UIReference.checkout.taxPriceText} ${UIReference.general.genericPriceSymbol}`); + // this.grandTotalElement = page.getByText('Grand Total $'); + this.grandTotalElement = page.getByText(`${UIReference.financial.grandTotal} ${UIReference.general.genericPriceSymbol}`); + this.paymentMethodOptionCreditCard = this.page.getByLabel(UIReference.checkout.paymentOptionCreditCardLabel); + this.paymentMethodOptionPaypal = this.page.getByLabel(UIReference.checkout.paymentOptionPaypalLabel); + this.creditCardNumberField = this.page.getByLabel(UIReference.checkout.creditCardNumberLabel); + this.creditCardExpiryField = this.page.getByLabel(UIReference.checkout.creditCardExpiryLabel); + this.creditCardCVVField = this.page.getByLabel(UIReference.checkout.creditCardCVVLabel); + this.creditCardNameField = this.page.getByLabel(UIReference.checkout.creditCardNameLabel); + } + + // ============================================== + // Order-related methods + // ============================================== + + async placeOrder(){ + let orderPlacedNotification = outcomeMarker.checkout.orderPlacedNotification; + + // If we're not already on the checkout page, go there + if (!this.page.url().includes(slugs.checkout.checkoutSlug)) { + await this.page.goto(slugs.checkout.checkoutSlug); + } + + // If shipping method is not selected, select it + if (!(await this.shippingMethodOptionFixed.isChecked())) { + await this.shippingMethodOptionFixed.check(); + await this.waitForMagewireRequests(); + } + + await this.paymentMethodOptionCheck.check(); + await this.waitForMagewireRequests(); + + await expect(async() => { + // Ensure the payment method is now checked. + expect(this.paymentMethodOptionCheck).toBeChecked(); + }).toPass(); + + await this.placeOrderButton.click(); + await this.waitForMagewireRequests(); + + await this.page.waitForURL(new RegExp(slugs.checkout.purchaseSuccessSlug)); + + await expect.soft(this.page.getByText(orderPlacedNotification)).toBeVisible(); + let orderNumber = await this.page.locator('p').filter({ hasText: outcomeMarker.checkout.orderPlacedNumberText }); + + await expect(this.continueShoppingButton, `${outcomeMarker.checkout.orderPlacedNumberText} ${orderNumber}`).toBeVisible(); + return orderNumber; + } + + + // ============================================== + // Discount-related methods + // ============================================== + + async applyDiscountCodeCheckout(code: string){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountFormButton.click(); + await this.waitForMagewireRequests(); + } + + if(await this.page.getByText(`-${outcomeMarker.cart.priceReducedSymbols}`).isVisible()){ + // discount is already active. + let cancelCouponButton = this.page.getByRole('button', { name: UIReference.checkout.cancelDiscountButtonLabel }); + await cancelCouponButton.click(); + await this.waitForMagewireRequests(); + } + + let applyCouponCheckoutButton = this.page.getByRole('button', { name: UIReference.checkout.applyDiscountButtonLabel }); + let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel); + + await checkoutDiscountField.fill(code); + await applyCouponCheckoutButton.click(); + await this.waitForMagewireRequests(); + + await expect.soft(this.page.getByText(`${outcomeMarker.checkout.couponAppliedNotification}`),`Notification that discount code ${code} has been applied`).toBeVisible({timeout: 30000}); + const discountString = `Discount (${code})`; + // await expect(this.page.getByText(`-${outcomeMarker.checkout.checkoutPriceReducedSymbol}`),`'-$' should be visible on the page`).toBeVisible(); + + // Alternate checking method: the button 'Cancel Coupon' should become visible. + await expect(this.page.getByRole('button', {name: 'Cancel Coupon'})).toBeVisible(); + + await expect(async() => { + await expect(this.page.getByText(discountString),`discount marker is visible`).toBeVisible(); + }).toPass(); + } + + async enterWrongCouponCode(code: string){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountFormButton.click(); + await this.waitForMagewireRequests(); + } + + let applyCouponCheckoutButton = this.page.getByRole('button', { name: UIReference.checkout.applyDiscountButtonLabel }); + let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel); + await checkoutDiscountField.fill(code); + await applyCouponCheckoutButton.click(); + await this.waitForMagewireRequests(); + + await expect.soft(this.page.getByText(outcomeMarker.checkout.incorrectDiscountNotification), `Code should not work`).toBeVisible(); + await expect(checkoutDiscountField).toBeEditable(); + } + + async removeDiscountCode(){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountFormButton.click(); + await this.waitForMagewireRequests(); + } + + let cancelCouponButton = this.page.getByRole('button', {name: UIReference.cart.cancelCouponButtonLabel}); + await cancelCouponButton.click(); + await this.waitForMagewireRequests(); + + await expect.soft(this.page.getByText(outcomeMarker.checkout.couponRemovedNotification),`Notification should be visible`).toBeVisible(); + await expect(this.page.getByText(outcomeMarker.checkout.checkoutPriceReducedSymbol),`'-$' should not be on the page`).toBeHidden(); + // await expect(this.page.locator('#quote-summary div'). + // getByText(`Discount`),`The word 'Discount (' should not be on the page anymore`).toBeHidden(); + + let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel); + await expect(checkoutDiscountField).toBeEditable(); + } + + // ============================================== + // Price summary methods + // ============================================== + + async getPriceValue(element: Locator): Promise { + const priceText = await element.innerText(); + // Extract just the price part after the $ symbol + const match = priceText.match(/\$\s*([\d.]+)/); + return match ? parseFloat(match[1]) : 0; + } + + async verifyPriceCalculations() { + const subtotal = await this.getPriceValue(this.subtotalElement); + const shipping = await this.getPriceValue(this.shippingElement); + const tax = await this.getPriceValue(this.taxElement); + const grandTotal = await this.getPriceValue(this.grandTotalElement); + + const calculatedTotal = +(subtotal + shipping + tax).toFixed(2); + + expect(subtotal, `Subtotal (${subtotal}) should be greater than 0`).toBeGreaterThan(0); + expect(shipping, `Shipping cost (${shipping}) should be greater than 0`).toBeGreaterThan(0); + // Enable when tax settings are set. + //expect(tax, `Tax (${tax}) should be greater than 0`).toBeGreaterThan(0); + expect(grandTotal, `Grand total (${grandTotal}) should equal calculated total (${calculatedTotal})`).toBe(calculatedTotal); + } + + async selectPaymentMethod(method: 'check' | 'creditcard' | 'paypal'): Promise { + switch(method) { + case 'check': + await this.paymentMethodOptionCheck.check(); + break; + case 'creditcard': + await this.paymentMethodOptionCreditCard.check(); + // Fill credit card details + await this.creditCardNumberField.fill(inputValues.payment?.creditCard?.number || '4111111111111111'); + await this.creditCardExpiryField.fill(inputValues.payment?.creditCard?.expiry || '12/25'); + await this.creditCardCVVField.fill(inputValues.payment?.creditCard?.cvv || '123'); + await this.creditCardNameField.fill(inputValues.payment?.creditCard?.name || 'Test User'); + break; + case 'paypal': + await this.paymentMethodOptionPaypal.check(); + break; + } + + await this.waitForMagewireRequests(); + } + + async selectShippingMethod(method: 'fixed' | 'table rate'): Promise { + switch(method) { + case 'fixed': + await this.shippingMethodOptionFixed.check(); + break; + case 'table rate': + await this.shippingMethodTableRateFixed.check(); + break; + } + + await this.waitForMagewireRequests(); + } + + async fillShippingAddress() { + // Fill required shipping address fields + await this.page.getByLabel(UIReference.credentials.emailCheckoutFieldLabel, { exact: true }).fill(faker.internet.email()); + await this.page.getByLabel(UIReference.personalInformation.firstNameLabel).fill(faker.person.firstName()); + await this.page.getByLabel(UIReference.personalInformation.lastNameLabel).fill(faker.person.lastName()); + await this.page.getByLabel(UIReference.newAddress.streetAddressLabel).first().fill(faker.location.streetAddress()); + await this.page.getByLabel(UIReference.newAddress.zipCodeLabel).fill(faker.location.zipCode()); + await this.page.getByLabel(UIReference.newAddress.cityNameLabel).fill(faker.location.city()); + await this.page.getByLabel(UIReference.newAddress.phoneNumberLabel).fill(faker.phone.number({style: 'national'})); + + // Select country (if needed) + // await this.page.getByLabel('Country').selectOption('US'); + const country : string = faker.helpers.arrayElement(inputValues.addressCountries); + const countrySelectorField = this.page.getByLabel(UIReference.newAddress.countryLabel); + const stateInputField = this.page.getByRole('textbox', { name: UIReference.newAddress.provinceSelectLabel }); + const stateSelectorField = stateInputField.filter({ hasText: UIReference.newAddress.provinceSelectFilterLabel }); + + + // If default selected country == country we want to use for the test, + // don't re-select it. + const defaultSelectedCountry = await countrySelectorField.evaluate( + (select: HTMLSelectElement) => select.options[select.selectedIndex]?.text + ); + + if(country !== defaultSelectedCountry) { + await countrySelectorField.selectOption({label: country}); + // Add a 5 second wait to allow the region dropdown/field to update. + await this.page.waitForTimeout(5000); + } + + const regionDropdown = this.page.getByLabel(UIReference.newAddress.provinceSelectLabel); + const regionInputField = this.page.getByRole('textbox', {name: UIReference.newAddress.provinceSelectLabel}); + + // Select state + if(country !== 'United States') { + // await expect(regionDropdown, `Dropdown should not be visible`).toBeHidden(); + await expect(regionInputField, `State input field should be editable`).toBeEditable(); + await regionInputField.fill(faker.location.state()); + } else { + await expect(regionInputField, `Dropdown should not be visible`).toBeHidden(); + // await expect(regionDropdown, `State input field should be editable`).toBeEditable(); + await regionDropdown.selectOption(faker.location.state()); + // Timeout because Alpine uses an @input.debounce to delay the activation of the event + // Standard debounce is 250ms. + await this.page.waitForTimeout(1000); + } + + // Wait for any Magewire updates + await this.waitForMagewireRequests(); + } +} + +export default CheckoutPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/compare.page.ts b/dev/tests/e2e/base-tests/poms/frontend/compare.page.ts new file mode 100644 index 00000000000..aefce4cccd4 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/compare.page.ts @@ -0,0 +1,51 @@ +// @ts-check + +import { expect, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker } from '@config'; + +class ComparePage { + page: Page; + + constructor(page: Page) { + this.page = page; + } + + async removeProductFromCompare(product:string){ + let comparisonPageEmptyText = this.page.getByText(UIReference.comparePage.comparisonPageEmptyText); + // if the comparison page is empty, we can't remove anything + if (await comparisonPageEmptyText.isVisible()) { + return; + } + + const comparisonPageProductTitle = this.page.getByRole('link', {name: product}); + let removeFromCompareButton = this.page.getByLabel(`${UIReference.comparePage.removeCompareLabel} ${product}`); + await removeFromCompareButton.click(); + const messageLocator = this.page.locator(UIReference.general.messageLocator); + await messageLocator.waitFor(); + await this.page.getByRole('button', {name: UIReference.general.closeMessageLabel}).click(); + await expect(messageLocator, `notification toast should be hidden`).toBeHidden(); + await expect(comparisonPageProductTitle, `Link to product is no longer visible`).toBeHidden(); + } + + async addToCart(product:string){ + const successMessage = this.page.locator(UIReference.general.successMessageLocator); + let productAddedNotification = this.page.getByText(`${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`); + + const productCell = this.page.getByRole('cell', {name: product}); + const addToCartButton = productCell.getByRole('button', {name: UIReference.general.addToCartLabel}); + + await addToCartButton.click(); + await successMessage.waitFor(); + await expect(productAddedNotification).toBeVisible(); + } + + async addToWishList(product:string){ + const successMessage = this.page.locator(UIReference.general.successMessageLocator); + let addToWishlistButton = this.page.getByLabel(`${UIReference.comparePage.addToWishListLabel} ${product}`); + let productAddedNotification = this.page.getByText(`${product} ${outcomeMarker.wishListPage.wishListAddedNotification}`); + + await addToWishlistButton.click(); + await successMessage.waitFor(); + } +} +export default ComparePage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/contact.page.ts b/dev/tests/e2e/base-tests/poms/frontend/contact.page.ts new file mode 100644 index 00000000000..a34fcfa40d8 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/contact.page.ts @@ -0,0 +1,42 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { faker } from '@faker-js/faker'; +import { UIReference, outcomeMarker, slugs } from '@config'; + +class ContactPage { + readonly page: Page; + readonly nameField: Locator; + readonly emailField: Locator; + readonly messageField: Locator; + readonly sendFormButton: Locator; + + constructor(page: Page){ + this.page = page; + this.nameField = this.page.getByLabel(UIReference.credentials.nameFieldLabel); + this.emailField = this.page.getByPlaceholder(UIReference.credentials.emailFieldLabel, { exact: true }); + this.messageField = this.page.locator(UIReference.contactPage.messageFieldSelector); + this.sendFormButton = this.page.getByRole('button', { name: UIReference.general.genericSubmitButtonLabel }); + } + + async fillOutForm(){ + await this.page.goto(slugs.contact.contactSlug); + let messageSentConfirmationText = outcomeMarker.contactPage.messageSentConfirmationText; + + // Add a wait for the form to be visible + await this.nameField.waitFor({state: 'visible', timeout: 10000}); + + await this.nameField.fill(faker.person.firstName()); + await this.emailField.fill(faker.internet.email()); + await this.messageField.fill(faker.lorem.paragraph()); + + await this.sendFormButton.click(); + + await expect(this.page.getByText(messageSentConfirmationText)).toBeVisible(); + await expect(this.nameField, 'name should be empty now').toBeEmpty(); + await expect(this.emailField, 'email should be empty now').toBeEmpty(); + await expect(this.messageField, 'message should be empty now').toBeEmpty(); + } +} + +export default ContactPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/footer.page.ts b/dev/tests/e2e/base-tests/poms/frontend/footer.page.ts new file mode 100644 index 00000000000..bfe50bad81e --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/footer.page.ts @@ -0,0 +1,25 @@ +// @ts-check + +import { expect, Locator, type Page } from '@playwright/test'; +import { UIReference } from '@config'; + +class Footer { + readonly page: Page + readonly footerElement: Locator + + + constructor(page: Page) { + this.page = page + this.footerElement = this.page.locator(UIReference.footerPage.footerLocator); + } + + async goToFooterElement () { + await this.page.getByText(UIReference.footerPage.currencyLabel).scrollIntoViewIfNeeded(); + await expect( + this.footerElement, + 'Footer is visible' + ).toBeVisible(); + } +} + +export default Footer; diff --git a/dev/tests/e2e/base-tests/poms/frontend/home.page.ts b/dev/tests/e2e/base-tests/poms/frontend/home.page.ts new file mode 100644 index 00000000000..2d642f28016 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/home.page.ts @@ -0,0 +1,25 @@ +// @ts-check + +import { type Page } from '@playwright/test'; +import { UIReference } from '@config'; + +class HomePage { + + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async addHomepageProductToCart(){ + let buyProductButton = this.page.getByRole('button').filter({hasText: UIReference.general.addToCartLabel}).first(); + + if(await buyProductButton.isVisible()) { + await buyProductButton.click(); + } else { + throw new Error(`No 'Add to Cart' button found on homepage`); + } + } +} + +export default HomePage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/login.page.ts b/dev/tests/e2e/base-tests/poms/frontend/login.page.ts new file mode 100644 index 00000000000..fbc9bb9ae04 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/login.page.ts @@ -0,0 +1,54 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, slugs } from '@config'; +import MainmenuPage from '@poms/frontend/mainmenu.page'; + +class LoginPage { + readonly page: Page; + readonly loginEmailField: Locator; + readonly loginPasswordField: Locator; + readonly loginButton: Locator; + + constructor(page: Page) { + this.page = page; + this.loginEmailField = page.getByRole('textbox', {name: UIReference.credentials.emailFieldLabel, exact: true}); + this.loginPasswordField = page.getByRole('textbox', {name: UIReference.credentials.passwordFieldLabel}); + this.loginButton = page.getByRole('button', { name: UIReference.credentials.loginButtonLabel }); + } + + async login(email: string, password: string){ + const mainmenu = new MainmenuPage(this.page); + + await this.page.goto(slugs.account.loginSlug); + await this.loginEmailField.fill(email); + await this.loginPasswordField.fill(password); + // usage of .press("Enter") to prevent webkit issues with button.click(); + await this.loginButton.press("Enter"); + // await this.loginButton.click({force: true}); + + await this.page.waitForLoadState(); + // await this.page.waitForURL(new RegExp(`${slugs.account.accountOverviewRegex}`)); + // await this.page.waitForURL(`**${slugs.account.accountOverviewSlug}`); + + // wait for page to be done loading + // await this.page.waitForURL('/customer/account/'); + + // Open the menu, then check the 'Sign Out' button is visible + await mainmenu.mainMenuAccountButton.waitFor(); + await mainmenu.mainMenuAccountButton.click(); + await expect(mainmenu.mainMenuLogoutItem, 'Sign Out button is visible, user is logged in').toBeVisible(); + } + + async loginExpectError(email: string, password: string, errorMessage: string) { + await this.page.goto(slugs.account.loginSlug); + await this.loginEmailField.fill(email); + await this.loginPasswordField.fill(password); + await this.loginButton.press('Enter'); + await this.page.waitForLoadState('networkidle'); + + await expect(this.page, 'Should stay on login page').toHaveURL(new RegExp(slugs.account.loginSlug)); + } +} + +export default LoginPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/mainmenu.page.ts b/dev/tests/e2e/base-tests/poms/frontend/mainmenu.page.ts new file mode 100644 index 00000000000..fe17994c77b --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/mainmenu.page.ts @@ -0,0 +1,227 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, slugs } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +// Timeout used to check our authenticated state. +const CUSTOMER_DATA_TIMEOUT = 10_000; + +class MainMenuPage { + readonly page: Page; + readonly mainMenuElement: Locator; + readonly mainMenuAccountButton: Locator; + readonly mainMenuMiniCartButton: Locator; + readonly mainMenuMyAccountItem: Locator; + readonly mainMenuSearchButton: Locator; + readonly mainMenuLoginItem: Locator; + readonly mainMenuCreateAccountButton: Locator; + readonly mainMenuWishListButton: Locator; + readonly mainMenuMyOrdersButton: Locator; + readonly mainMenuAddressBookButton: Locator; + readonly mainMenuLogoutItem: Locator; + + constructor(page: Page) { + this.page = page; + this.mainMenuElement = page.locator(UIReference.general.headerLocator); + this.mainMenuAccountButton = this.mainMenuElement.getByRole('button', { name: UIReference.mainMenu.myAccountButtonLabel }); + // this.mainMenuMiniCartButton = this.mainMenuElement.getByLabel(UIReference.mainMenu.miniCartLabel); + this.mainMenuMiniCartButton = this.mainMenuElement.getByRole('button', {name: UIReference.mainMenu.miniCartLabel}); + this.mainMenuMyAccountItem = this.mainMenuElement.getByTitle(UIReference.mainMenu.myAccountButtonLabel); + this.mainMenuSearchButton = this.mainMenuElement.getByRole('button', {name: UIReference.mainMenu.searchButtonLabel}); + + this.mainMenuLoginItem = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.loginButtonLabel}); + this.mainMenuCreateAccountButton = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.createAccountButtonLabel}); + this.mainMenuWishListButton = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.wishListButtonLabel}); + this.mainMenuMyOrdersButton = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.myOrdersButtonLabel}); + this.mainMenuAddressBookButton = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.addressBookButtonLabel}); + this.mainMenuLogoutItem = this.mainMenuElement.getByTitle(UIReference.mainMenu.myAccountLogoutItem); + } + + /** + * Opens the account menu and waits for the correct menu items to appear. + * Magento's customer section data loads asynchronously via JS/Alpine, + * so the menu initially shows guest items before updating. + * @param loggedIn - If true, waits for logged-in menu items; if false (default), waits for guest items. + */ + async openAccountMenu(loggedIn = false) { + // Workaround: the homepage has a known issue where the header menu + // does not update to reflect logged-in state. We navigate to the account page. + const url = loggedIn ? slugs.account.accountOverviewSlug : requireEnv('PLAYWRIGHT_BASE_URL'); + await this.page.goto(url, { waitUntil: 'load' }); + + await this.mainMenuAccountButton.waitFor(); + await this.mainMenuAccountButton.click(); + + const expectedItem = loggedIn ? this.mainMenuMyAccountItem : this.mainMenuLoginItem; + await expectedItem.waitFor({ timeout: CUSTOMER_DATA_TIMEOUT }); + } + + /** + * Function for the test Navigate_to_category_page + */ + async goToCategoryPage() { + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + await this.mainMenuAccountButton.waitFor(); + await this.page.getByRole('link', { name: UIReference.categoryPage.categoryPageTitleText, exact: true }).click(); + + await expect(this.page.getByRole('heading', {name: UIReference.categoryPage.categoryPageTitleText}), + `Heading "${UIReference.categoryPage.categoryPageTitleText}" is visible`).toBeVisible(); + } + + /** + * Function for the test Navigate_to_subcategory_page + */ + async goToSubCategoryPage() { + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + await this.mainMenuAccountButton.waitFor(); + const categoryLink = this.page.getByRole('link', { name: UIReference.mainMenu.categoryItemText, exact: true }); + + // FIREFOX_WORKAROUND: focus on element first (note: does not always work) + // See: https://github.com/microsoft/playwright/issues/27969 + await categoryLink.focus(); + await categoryLink.hover(); + await expect(this.page.getByRole('link', {name: UIReference.mainMenu.subCategoryItemText})).toBeVisible(); + await this.page.getByRole('link', {name: UIReference.mainMenu.subCategoryItemText}).click(); + + await expect(this.page.getByRole('heading',{ name: outcomeMarker.categoryPage.subCategoryPageTitle }), + `Category page title "${outcomeMarker.categoryPage.subCategoryPageTitle}" is visible`).toBeVisible(); + } + + /** + * Function for the test User_navigates_account_page + */ + async gotoMyAccount(){ + await this.mainMenuMyAccountItem.click(); + + await expect(this.page.getByRole('heading', { name: UIReference.accountDashboard.accountDashboardTitleLabel }), + 'Account dashboard is visible').toBeVisible(); + } + + /** + * Function for the test User_navigates_to_login + */ + async goToLoginPage() { + const loginHeader = this.page.getByRole('heading', {name: outcomeMarker.login.loginHeaderText, exact:true}); + await this.openAccountMenu(); + + await this.mainMenuLoginItem.click(); + const loginRegEx = new RegExp(`${slugs.account.loginSlugRegex}`); + await this.page.waitForURL(loginRegEx); + await expect(loginHeader, 'Login header text is visible').toBeVisible(); + } + + /** + * Function for the test User_navigates_to_create_account + */ + async goToCreateAccountPage() { + const createAccountHeader = this.page.getByRole('heading', {name: outcomeMarker.account.createAccountHeaderText, exact:true}); + await this.openAccountMenu(); + + await this.mainMenuCreateAccountButton.click(); + await expect(createAccountHeader, 'Create account header text is visible').toBeVisible(); + } + + /** + * Function to navigate to address book using the menu + * @assume the user is already on a (loaded) page. + */ + async goToAddressBook() { + await this.mainMenuAddressBookButton.click(); + + if(this.page.url().includes('new')) { + // no address has been added yet + await expect(this.page.getByRole( 'heading', {name: UIReference.newAddress.addNewAddressTitle, level: 1, exact:true}), + `Heading "${UIReference.newAddress.addNewAddressTitle}" is visible`).toBeVisible(); + } else { + await expect(this.page.getByRole('heading', {name: UIReference.address.addressBookTitle, level: 1, exact: true}), + `Heading "${UIReference.address.addressBookTitle}" is visible`).toBeVisible(); + } + } + + /** + * Function for the test Navigate_to_orders + * @assume the user is already on a (loaded) page. + */ + async goToOrders() { + await this.mainMenuMyOrdersButton.click(); + + await expect(this.page.getByRole('heading', {name: UIReference.orderHistoryPage.orderHistoryTitle, level: 1, exact:true}), + `Heading "${UIReference.orderHistoryPage.orderHistoryTitle}" is visible`).toBeVisible(); + } + + /** + * Function for the test Navigate_to_wishlist + * @assume the user is already on a (loaded) page. + */ + async goToWishList() { + await this.mainMenuWishListButton.click(); + await this.page.waitForURL(new RegExp(slugs.wishList.wishListSlug)); + + await expect(this.page.getByRole('heading', {name: UIReference.wishListPage.wishListTitle, exact:true}), + `Heading "${UIReference.wishListPage.wishListTitle}" is visible`).toBeVisible(); + } + + /** + * Function for the test Open_the_minicart + */ + async openMiniCart() { + await this.mainMenuMiniCartButton.waitFor(); + // Trial first, since 'force' skips the actionability check + await this.mainMenuMiniCartButton.click({trial: true}); + // By adding 'force', we can bypass the 'aria-disabled' tag. + await this.mainMenuMiniCartButton.click({force: true}); + + let miniCartDrawer = this.page.locator(UIReference.miniCart.cartDrawerLocator); + await expect(async() => { + await expect(miniCartDrawer.getByText(outcomeMarker.miniCart.miniCartTitle)).toBeVisible(); + }).toPass(); + } + + /** + * Used for function User_searches_for_product + * @param searchTerm + */ + async searchForProduct(searchTerm :string) { + const searchField = this.page.getByRole('searchbox', { name: UIReference.search.searchBoxPlaceholderText }); + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL'), {waitUntil:'load'}); + await this.mainMenuAccountButton.waitFor(); + + await this.mainMenuSearchButton.click(); + await expect(searchField, 'Search field is visible').toBeVisible(); + await searchField.fill(searchTerm); + await expect(this.page.getByText(UIReference.search.searchTermDropdownText, { exact: true }), 'Dropdown with results is visible').toBeVisible(); + await searchField.press('Enter'); + + await this.page.waitForURL(`**/?q=${searchTerm}`); + await expect(this.page.getByRole('heading', { name: `${UIReference.search.searchResultsTitle} \'${searchTerm}\'` }), + `Title contains search term: "${searchTerm}"`).toBeVisible(); + } + + /** + * Function for the test User_logs_out + */ + async logout(){ + // Use a server-side check: navigating to account overview redirects to login if not authenticated. + await this.page.goto(slugs.account.accountOverviewSlug, { waitUntil: 'load' }); + + // Redirected to login page, we're already logged out. + if(this.page.url().includes(slugs.account.loginSlug)) { return; } + + // We're on the account page, so we're logged in. Use the menu to log out. + await this.mainMenuAccountButton.waitFor(); + await this.mainMenuAccountButton.click(); + await this.mainMenuLogoutItem.waitFor({ timeout: CUSTOMER_DATA_TIMEOUT }); + await this.mainMenuLogoutItem.click(); + + //assertions: notification that user is logged out & logout button no longer visible + await expect(this.page.getByText(outcomeMarker.logout.logoutConfirmationText, { exact: true }), + "Message shown that confirms you're logged out").toBeVisible(); + await expect(this.mainMenuLogoutItem, `Log out button is no longer visible`).toBeHidden(); + + // since the page automatically navigates to the home page, wait until we're there. + await this.page.waitForURL(requireEnv(`PLAYWRIGHT_BASE_URL`)); + } +} + +export default MainMenuPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/minicart.page.ts b/dev/tests/e2e/base-tests/poms/frontend/minicart.page.ts new file mode 100644 index 00000000000..8afc7a104db --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/minicart.page.ts @@ -0,0 +1,72 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, slugs } from '@config'; + +class MiniCartPage { + readonly page: Page; + readonly toCheckoutButton: Locator; + readonly toCartButton: Locator; + readonly editProductButton: Locator; + readonly productQuantityField: Locator; + readonly updateItemButton: Locator; + readonly priceOnPDP: Locator; + readonly priceInMinicart: Locator; + + constructor(page: Page) { + this.page = page; + this.toCheckoutButton = page.getByRole('link', { name: UIReference.miniCart.checkOutButtonLabel }); + this.toCartButton = page.getByRole('link', { name: UIReference.miniCart.toCartLinkLabel }); + this.editProductButton = page.getByLabel(UIReference.miniCart.editProductIconLabel); + this.productQuantityField = page.getByLabel(UIReference.miniCart.productQuantityFieldLabel); + this.updateItemButton = page.getByRole('button', { name: UIReference.cart.updateItemButtonLabel }); + this.priceOnPDP = page.getByLabel(UIReference.general.genericPriceLabel).getByText(UIReference.general.genericPriceSymbol); + this.priceInMinicart = page.getByText(UIReference.general.genericPriceSymbol).first(); + } + + async goToCheckout(){ + await this.toCheckoutButton.click(); + await expect(this.page).toHaveURL(new RegExp(`${slugs.checkout.checkoutSlug}.*`)); + } + + async goToCart(){ + await this.toCartButton.click(); + await expect(this.page).toHaveURL(new RegExp(`${slugs.cart.cartSlug}.*`)); + } + + async removeProductFromMinicart(product: string) { + let productRemovedNotification = outcomeMarker.miniCart.productRemovedConfirmation; + let removeProductMiniCartButton = this.page.getByLabel(`${UIReference.miniCart.removeProductIconLabel} "${UIReference.productPage.simpleProductTitle}"`); + // ensure button is visible + await removeProductMiniCartButton.waitFor(); + await removeProductMiniCartButton.click(); + await expect(removeProductMiniCartButton, `Button to move product from minicart is no longer visible`).toBeHidden(); + await expect(this.page.getByText(UIReference.miniCart.cartEmptyText), `Minicart shows text "Cart is empty"`).toBeVisible(); + } + + async updateProduct(amount: string){ + let productQuantityChangedNotification = outcomeMarker.miniCart.productQuantityChangedConfirmation; + await this.editProductButton.click(); + await expect(this.page).toHaveURL(new RegExp(`${slugs.cart.cartProductChangeSlug}.*`)); + + await this.productQuantityField.click(); + await this.productQuantityField.fill(amount); + + await this.updateItemButton.click(); + await expect.soft(this.page.getByText(productQuantityChangedNotification)).toBeVisible(); + + let productQuantityInCart = await this.page.getByLabel(UIReference.cart.cartQuantityLabel).first().inputValue(); + expect(productQuantityInCart).toBe(amount); + } + + async checkPriceWithProductPage() { + const priceOnPage = await this.page.locator(UIReference.productPage.simpleProductPrice).first().innerText(); + const productTitle = await this.page.getByRole('heading', { level : 1}).innerText(); + const productListing = this.page.locator('div').filter({hasText: productTitle}); + const priceInMinicart = await productListing.locator(UIReference.miniCart.minicartPriceFieldClass).first().textContent(); + //expect(priceOnPage).toBe(priceInMinicart); + expect(priceOnPage, `Expect these prices to be the same: priceOnpage: ${priceOnPage} and priceInMinicart: ${priceInMinicart}`).toBe(priceInMinicart); + } +} + +export default MiniCartPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/newsletter.page.ts b/dev/tests/e2e/base-tests/poms/frontend/newsletter.page.ts new file mode 100644 index 00000000000..ec8a26eee4a --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/newsletter.page.ts @@ -0,0 +1,49 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, inputValues } from '@config'; +import { faker } from '@faker-js/faker' + +class NewsletterSubscriptionPage { + readonly page: Page; + readonly newsletterCheckElement: Locator; + readonly saveSubscriptionsButton: Locator; + + constructor(page: Page) { + this.page = page; + this.newsletterCheckElement = page.getByLabel(UIReference.newsletterSubscriptions.generalSubscriptionCheckLabel); + this.saveSubscriptionsButton = page.getByRole('button', {name:UIReference.newsletterSubscriptions.saveSubscriptionsButton}); + } + + async updateNewsletterSubscription(){ + + let subscriptionUpdatedNotification = outcomeMarker.account.newsletterRemovedNotification; + let subscribed = false; + + if(await this.newsletterCheckElement.isChecked()) { + // user is already subscribed, test runs unsubscribe + await this.newsletterCheckElement.uncheck(); + await this.saveSubscriptionsButton.click(); + + } else { + // user is not yet subscribed, test runs subscribe + subscriptionUpdatedNotification = outcomeMarker.account.newsletterSavedNotification; + + await this.newsletterCheckElement.check(); + await this.saveSubscriptionsButton.click(); + + subscribed = true; + } + + await expect(this.page.getByText(subscriptionUpdatedNotification)).toBeVisible(); + return subscribed; + } + + async footerSubscribeToNewsletter() { + await expect(this.page.getByRole('textbox', {name: UIReference.footerPage.newsletterInputElementLabel})).toBeVisible(); + await this.page.getByRole('textbox', {name: UIReference.footerPage.newsletterInputElementLabel}).fill(faker.internet.email()); + await this.page.getByRole('button', {name: UIReference.footerPage.newsletterSubscribeButtonLabel}).click(); + } +} + +export default NewsletterSubscriptionPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/orderhistory.page.ts b/dev/tests/e2e/base-tests/poms/frontend/orderhistory.page.ts new file mode 100644 index 00000000000..1be0ee6973e --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/orderhistory.page.ts @@ -0,0 +1,23 @@ +// @ts-check + +import { expect, type Page } from '@playwright/test'; +import { slugs } from '@config'; + +class OrderHistoryPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async open() { + await this.page.goto(slugs.account.orderHistorySlug); + await this.page.waitForLoadState(); + } + + async verifyOrderPresent(orderNumber: string) { + await expect(this.page.getByText(orderNumber)).toBeVisible(); + } +} + +export default OrderHistoryPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/product.page.ts b/dev/tests/e2e/base-tests/poms/frontend/product.page.ts new file mode 100644 index 00000000000..5c4ec491775 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/product.page.ts @@ -0,0 +1,195 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, slugs } from '@config'; + +class ProductPage { + readonly page: Page; + simpleProductTitle: Locator | undefined; + configurableProductTitle: Locator | undefined; + addToCartButton: Locator; + addToCompareButton: Locator; + addToWishlistButton: Locator; + + constructor(page: Page) { + this.page = page; + this.addToCartButton = page.getByRole('button', { name: UIReference.productPage.addToCartButtonLocator, exact:true }); + this.addToCompareButton = page.getByLabel(UIReference.productPage.addToCompareButtonLabel, { exact: true }); + this.addToWishlistButton = page.getByLabel(UIReference.productPage.addToWishlistButtonLabel, { exact: true }); + } + + // ============================================== + // Productpage-related methods + // ============================================== + + async addProductToCompare(product:string, url: string){ + let productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} product`; + const successMessage = this.page.locator(UIReference.general.successMessageLocator); + + await this.page.goto(url); + + await this.addToCompareButton.click(); + await successMessage.waitFor(); + await expect(this.page.getByText(productAddedNotification)).toBeVisible(); + + await this.page.goto(slugs.productPage.productComparisonSlug); + + // Assertion: a cell with the product name inside a cell with the product name should be visible + await expect(this.page.getByRole('cell', {name: product}).getByText(product, {exact: true})).toBeVisible(); + } + + async addProductToWishlist(product:string, url: string){ + /** + * Note that the test Add_product_to_wishlist is currently set to fixme + */ + let addedToWishlistNotification = `${product} ${outcomeMarker.wishListPage.wishListAddedNotification}`; + await this.page.goto(url); + await this.addToWishlistButton.waitFor(); + this.addToWishlistButton.click(); + + await expect(async () => { + await this.page.waitForSelector(UIReference.general.messageLocator, { state: 'visible' }); + }).toPass(); + + await expect(this.page.getByText(addedToWishlistNotification), "Notification that product has been added is visible").toBeVisible(); + + await expect(async () => { + await expect(this.page.getByText(addedToWishlistNotification)).toBeVisible(); + }).toPass(); + + let productNameInWishlist = this.page.locator(UIReference.wishListPage.wishListItemGridLabel).getByText(UIReference.productPage.simpleProductTitle, {exact: true}); + + await expect(this.page).toHaveURL(new RegExp(slugs.wishList.wishListRegex)); + await expect(this.page.getByText(addedToWishlistNotification)).toBeVisible(); + await expect(productNameInWishlist).toContainText(product); + } + + async leaveProductReview(product:string, url: string){ + + await this.page.goto(url); + + //TODO: Uncomment this and fix test once website is fixed + /* + await page.locator('#Rating_5_label path').click(); + await page.getByPlaceholder('Nickname*').click(); + await page.getByPlaceholder('Nickname*').fill('John'); + await page.getByPlaceholder('Nickname*').press('Tab'); + await page.getByPlaceholder('Summary*').click(); + await page.getByPlaceholder('Summary*').fill('A short paragraph'); + await page.getByPlaceholder('Review*').click(); + await page.getByPlaceholder('Review*').fill('Review message!'); + await page.getByRole('button', { name: 'Submit Review' }).click(); + await page.getByRole('img', { name: 'loader' }).click(); + */ + } + + async openLightboxAndScrollThrough(url: string){ + + await this.page.goto(url); + let fullScreenOpener = this.page.getByLabel(UIReference.productPage.fullScreenOpenLabel); + let fullScreenCloser = this.page.getByLabel(UIReference.productPage.fullScreenCloseLabel); + let thumbnails = this.page.getByRole('button', {name: UIReference.productPage.thumbnailImageLabel}); + + await fullScreenOpener.click(); + await expect(fullScreenCloser).toBeVisible(); + + for (const img of await thumbnails.all()) { + await img.click(); + // wait for transition animation + await this.page.waitForTimeout(500); + await expect(img, `CSS class 'border-primary' appended to button`).toHaveClass(new RegExp(outcomeMarker.productPage.borderClassRegex)); + } + + await fullScreenCloser.click(); + await expect(fullScreenCloser).toBeHidden(); + + } + + async changeReviewCountAndVerify(url: string) { + + await this.page.goto(url); + + // Get the default review count from URL or UI + const initialUrl = this.page.url(); + + // Find and click the review count selector + const reviewCountSelector = this.page.getByLabel(UIReference.productPage.reviewCountLabel); + await expect(reviewCountSelector).toBeVisible(); + + // Select 20 reviews per page + await reviewCountSelector.selectOption('20'); + await this.page.waitForURL(/.*limit=20.*/); + + // Verify URL contains the new limit + const urlAfterFirstChange = this.page.url(); + expect(urlAfterFirstChange, 'URL should contain limit=20 parameter').toContain('limit=20'); + expect(urlAfterFirstChange, 'URL should have changed after selecting 20 items per page').not.toEqual(initialUrl); + + // Select 50 reviews per page + await reviewCountSelector.selectOption('50'); + await this.page.waitForURL(/.*limit=50.*/); + + // Verify URL contains the new limit + const urlAfterSecondChange = this.page.url(); + expect(urlAfterSecondChange, 'URL should contain limit=50 parameter').toContain('limit=50'); + expect(urlAfterSecondChange, 'URL should have changed after selecting 50 items per page').not.toEqual(urlAfterFirstChange); + } + + // ============================================== + // Cart-related methods + // ============================================== + + async addSimpleProductToCart(product: string, url: string, quantity?: string) { + + await this.page.goto(url); + + const productInfo = this.page.getByRole('region', { name: 'Product Info' }); + this.simpleProductTitle = productInfo.getByText(product, {exact:true}); + await expect(this.simpleProductTitle).toBeVisible(); + + if(quantity){ + // set quantity + await this.page.getByRole('spinbutton', {name: UIReference.productPage.quantityFieldLabel}).fill('2'); + } + + // assert visibility to ensure we can click the add to cart button. + await expect(this.addToCartButton).toBeVisible(); + await this.addToCartButton.click(); + + await expect(this.page.locator(UIReference.general.messageLocator).filter( + {hasText: `${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`}), + `Product has been added to cart` + ).toBeVisible(); + + } + + async addConfigurableProductToCart(product: string, url:string, quantity?:string) { + + await this.page.goto(url); + + this.configurableProductTitle = this.page.getByLabel('Product Info').getByText(product, {exact:true}); + let productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`; + const productOptions = this.page.locator(UIReference.productPage.configurableProductOptionForm); + + // wait for the color and size selectors are actually visible + await productOptions.getByRole('radiogroup').first().waitFor(); + await productOptions.getByRole('radiogroup').last().waitFor(); + + // loop through each radiogroup (product option) within the form + for (const option of await productOptions.getByRole('radiogroup').all()) { + await option.locator(UIReference.productPage.configurableProductOptionValue).first().check(); + } + + if(quantity){ + // set quantity + await this.page.getByLabel(UIReference.productPage.quantityFieldLabel).fill('2'); + } + + await this.addToCartButton.click(); + let successMessage = this.page.locator(UIReference.general.successMessageLocator); + await successMessage.waitFor(); + await expect(this.page.getByText(productAddedNotification)).toBeVisible(); + } +} + +export default ProductPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/register.page.ts b/dev/tests/e2e/base-tests/poms/frontend/register.page.ts new file mode 100644 index 00000000000..2246c43da61 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/register.page.ts @@ -0,0 +1,58 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, slugs} from '@config'; + +class RegisterPage { + readonly page: Page; + readonly accountCreationFirstNameField: Locator; + readonly accountCreationLastNameField: Locator; + readonly accountCreationEmailField: Locator; + readonly accountCreationPasswordField: Locator; + readonly accountCreationPasswordRepeatField: Locator; + readonly accountCreationConfirmButton: Locator; + + constructor(page: Page){ + this.page = page; + this.accountCreationFirstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel); + this.accountCreationLastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel); + this.accountCreationEmailField = page.getByRole('textbox', {name: UIReference.credentials.emailFieldLabel, exact: true}); + this.accountCreationPasswordField = page.getByRole('textbox', {name: UIReference.credentials.passwordFieldLabel, exact:true}); + this.accountCreationPasswordRepeatField = page.getByRole('textbox', {name: UIReference.credentials.passwordConfirmFieldLabel}); + this.accountCreationConfirmButton = page.getByRole('button', {name: UIReference.accountCreation.createAccountButtonLabel}); + } + + + async createNewAccount(firstName: string, lastName: string, email: string, password: string, isSetup: boolean = false){ + let accountInformationField = this.page.locator(UIReference.accountDashboard.accountInformationFieldLocator).first(); + await this.page.goto(slugs.account.createAccountSlug); + + await expect(async () => { + await expect(this.page.getByRole('heading', + { name: UIReference.accountCreation.createAccountTitleText }), + `Heading "${UIReference.accountCreation.createAccountTitleText}" is visible`).toBeVisible(); + }).toPass(); + + await this.accountCreationFirstNameField.fill(firstName); + await this.accountCreationLastNameField.fill(lastName); + await this.accountCreationEmailField.fill(email); + await this.accountCreationPasswordField.fill(password); + await this.accountCreationPasswordRepeatField.fill(password); + await this.accountCreationConfirmButton.click(); + + if(!isSetup) { + await this.page.waitForLoadState(); + // Assertions: Account created notification, navigated to account page, email visible on page + await expect(this.page.getByText(outcomeMarker.account.accountCreatedNotificationText), 'Account creation notification should be visible').toBeVisible(); + + await this.page.goto(slugs.account.accountOverviewSlug); + await expect(this.page.getByRole('heading', + {name: UIReference.accountDashboard.accountDashboardTitleLabel, level:2}), + `Heading "${UIReference.accountDashboard.accountDashboardTitleLabel}" is visible`).toBeVisible(); + // await expect(this.page, 'Should be redirected to account overview page').toHaveURL(new RegExp('.+' + slugs.account.accountOverviewSlug)); + await expect(accountInformationField, `Account information should contain email: ${email}`).toContainText(email); + } + } +} + +export default RegisterPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/search.page.ts b/dev/tests/e2e/base-tests/poms/frontend/search.page.ts new file mode 100644 index 00000000000..44ec11849fe --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/search.page.ts @@ -0,0 +1,33 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference } from '@config'; + +class SearchPage { + readonly page: Page; + readonly searchToggle: Locator; + readonly searchInput: Locator; + readonly suggestionBox: Locator; + + constructor(page: Page) { + this.page = page; + this.searchToggle = page.locator(UIReference.search.searchToggleLocator); + this.searchInput = page.locator(UIReference.search.searchInputLocator); + this.suggestionBox = page.locator(UIReference.search.suggestionBoxLocator); + } + + async openSearch() { + await this.searchToggle.waitFor({ state: 'visible' }); + await this.searchToggle.click(); + await expect(this.searchInput).toBeVisible(); + } + + async search(query: string) { + await this.openSearch(); + await this.searchInput.fill(query); + await this.searchInput.press('Enter'); + await this.page.waitForLoadState('networkidle'); + } +} + +export default SearchPage; diff --git a/dev/tests/e2e/base-tests/poms/frontend/shoppingcart.page.ts b/dev/tests/e2e/base-tests/poms/frontend/shoppingcart.page.ts new file mode 100644 index 00000000000..acff717cc82 --- /dev/null +++ b/dev/tests/e2e/base-tests/poms/frontend/shoppingcart.page.ts @@ -0,0 +1,158 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker } from '@config'; + +class CartPage { + readonly page: Page; + readonly showDiscountButton: Locator; + productQuantityInCheckout: string | undefined; + productPriceInCheckout: string | undefined; + + constructor(page: Page) { + this.page = page; + // this.showDiscountButton = this.page.getByRole('button', { name: UIReference.cart.showDiscountFormButtonLabel }); + this.showDiscountButton = this.page.locator('summary').filter({hasText: UIReference.cart.showDiscountFormButtonLabel}); + } + + async changeProductQuantity(amount: string){ + const productRow = this.page.getByRole('listitem').filter({hasText: UIReference.productPage.simpleProductTitle}); + let currentQuantity = await productRow.getByRole('spinbutton', {name: UIReference.cart.cartQuantityLabel}).inputValue(); + + if(currentQuantity == amount){ + amount = '3'; + } + + let subTotalBeforeUpdate = await productRow.getByText(UIReference.general.genericPriceSymbol).last().innerText(); + await productRow.getByLabel(UIReference.cart.cartQuantityLabel).fill(amount); + await this.page.getByRole('button', { name: UIReference.cart.updateShoppingCartButtonLabel }).click(); + + await expect(async () => { + let subTotalAfterUpdate = await productRow.getByText(UIReference.general.genericPriceSymbol).last().innerText(); + await expect(subTotalBeforeUpdate, `Subtotal should change`).not.toEqual(subTotalAfterUpdate); + }).toPass(); + + let updatedQuantity = await productRow.getByLabel(UIReference.cart.cartQuantityLabel).inputValue(); + expect(updatedQuantity, `updated quantity (${updatedQuantity}) should equal amount we've requested (${amount})`).toEqual(amount); + } + + // ============================================== + // Product-related methods + // ============================================== + + async removeProduct(productTitle: string){ + let removeButton = this.page.getByLabel(`${UIReference.general.removeLabel} ${productTitle}`); + await removeButton.click(); + await this.page.waitForLoadState(); + await expect(removeButton,`Button to remove specified product is not visible in the cart`).toBeHidden(); + + // Expect product to no longer be visible in the cart + await expect (this.page.getByRole('cell', { name: productTitle }), `Product is not visible in cart`).toBeHidden(); + } + + // ============================================== + // Discount-related methods + // ============================================== + async applyDiscountCode(code: string){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountButton.click(); + } + + let applyDiscoundButton = this.page.getByRole('button', {name: UIReference.cart.applyDiscountButtonLabel, exact:true}); + let discountField = this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel); + await discountField.fill(code); + await applyDiscoundButton.click(); + await this.page.waitForLoadState(); + + const notificationBanner = this.page.locator(UIReference.general.successMessageLocator) + .filter({hasText: outcomeMarker.cart.discountAppliedNotification}); + await notificationBanner.waitFor(); + + await expect.soft(this.page.getByText(`${outcomeMarker.cart.discountAppliedNotification} "${code}"`),`Notification that discount code ${code} has been applied`).toBeVisible(); + // WORKAROUND + // hardcoded '-' symbol because the space between - and $ is not always present. + await expect(this.page.getByText(`- ${outcomeMarker.cart.priceReducedSymbols}`),`'- $' should be visible on the page`).toBeVisible(); + //Close message to prevent difficulties with other tests. + await this.page.getByLabel(UIReference.general.closeMessageLabel).click(); + } + + async removeDiscountCode(){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountButton.click(); + } + + let cancelCouponButton = this.page.getByRole('button', {name: UIReference.cart.cancelCouponButtonLabel}); + await cancelCouponButton.click(); + await this.page.waitForLoadState(); + + await expect.soft(this.page.getByText(outcomeMarker.cart.discountRemovedNotification),`Notification should be visible`).toBeVisible(); + await expect(this.page.getByText(`-${outcomeMarker.cart.priceReducedSymbols}`),`'- $' should not be on the page`).toBeHidden(); + } + + async enterWrongCouponCode(code: string){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountButton.click(); + } + + let applyDiscoundButton = this.page.getByRole('button', {name: UIReference.cart.applyDiscountButtonLabel, exact:true}); + let discountField = this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel); + await discountField.fill(code); + await applyDiscoundButton.click(); + await this.page.waitForLoadState(); + + let incorrectNotification = `${outcomeMarker.cart.incorrectCouponCodeNotificationOne} "${code}" ${outcomeMarker.cart.incorrectCouponCodeNotificationTwo}`; + + //Assertions: notification that code was incorrect & discount code field is still editable + await expect.soft(this.page.getByText(incorrectNotification), `Code should not work`).toBeVisible(); + await expect(discountField).toBeEditable(); + } + + + // ============================================== + // Additional methods + // ============================================== + + async getCheckoutValues(productName:string, pricePDP:string, amountPDP:string){ + const checkoutCartDetails = this.page.locator(UIReference.checkout.checkoutCartDetailsLocator); + const openCartDetailsButton = this.page.locator(UIReference.checkout.openCartDetailsButtonLocator); + + if(await checkoutCartDetails.isHidden()) { + await openCartDetailsButton.click(); + } + + // // Open minicart based on amount of products in cart + // let cartItemAmount = await this.page.locator(UIReference.miniCart.minicartAmountBubbleLocator).count(); + // if(cartItemAmount == 1) { + // await this.page.getByLabel(`${UIReference.checkout.openCartButtonLabel} ${cartItemAmount} ${UIReference.checkout.openCartButtonLabelCont}`).click(); + // } else { + // await this.page.getByLabel(`${UIReference.checkout.openCartButtonLabel} ${cartItemAmount} ${UIReference.checkout.openCartButtonLabelContMultiple}`).click(); + // } + + // Get values from checkout page + let productInCheckout = this.page.locator(UIReference.checkout.cartDetailsLocator).filter({ hasText: productName }).nth(1); + this.productPriceInCheckout = await productInCheckout.getByText(UIReference.general.genericPriceSymbol).last().innerText(); + this.productPriceInCheckout = this.productPriceInCheckout.trim(); + // let productImage = this.page.locator(UIReference.checkout.cartDetailsLocator) + // .filter({ has: this.page.getByRole('img', { name: productName })}); + // this.productQuantityInCheckout = await productImage.locator('> span').innerText(); + this.productQuantityInCheckout = await productInCheckout.locator('.product-price').getByText('x').innerText(); + this.productQuantityInCheckout = this.productQuantityInCheckout.substring(0,1); + return [this.productPriceInCheckout, this.productQuantityInCheckout]; + } + + async calculateProductPricesAndCompare(pricePDP: string, amountPDP:string, priceCheckout:string, amountCheckout:string){ + // perform magic to calculate price * amount and mold it into the correct form again + pricePDP = pricePDP.replace(UIReference.general.genericPriceSymbol,''); + let pricePDPInt = Number(pricePDP); + let quantityPDPInt = parseInt(amountPDP); + let calculatedPricePDP = `${UIReference.general.genericPriceSymbol}` + (pricePDPInt * quantityPDPInt).toFixed(2); + + expect(amountPDP,`Amount on PDP (${amountPDP}) equals amount in checkout (${amountCheckout})`).toEqual(amountCheckout); + expect(calculatedPricePDP, `Price * qty on PDP (${calculatedPricePDP}) equals price * qty in checkout (${priceCheckout})`).toEqual(priceCheckout); + } +} + +export default CartPage; diff --git a/dev/tests/e2e/base-tests/product.spec.ts b/dev/tests/e2e/base-tests/product.spec.ts new file mode 100644 index 00000000000..0318a1df09d --- /dev/null +++ b/dev/tests/e2e/base-tests/product.spec.ts @@ -0,0 +1,51 @@ +// @ts-check + +import { test } from '@playwright/test'; +import { UIReference ,slugs } from '@config'; + +import ProductPage from '@poms/frontend/product.page'; +import LoginPage from '@poms/frontend/login.page'; +import { requireEnv } from '@utils/env.utils'; + +test.describe('Product page tests',{ tag: '@product',}, () => { + test('Add_product_to_compare',{ tag: '@cold'}, async ({page}) => { + const productPage = new ProductPage(page); + await productPage.addProductToCompare(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + }); + + test.fixme('Add_product_to_wishlist',{ tag: '@cold'}, async ({page, browserName}) => { + /** + * This test is currently (October 2025) set to be fixed, since it causes regular timeouts. + * Various fixes have been tried, unsuccessfully. + */ + await test.step('Log in with account', async () =>{ + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + const loginPage = new LoginPage(page); + await loginPage.login(emailInputValue, passwordInputValue); + }); + + await test.step('Add product to wishlist', async () =>{ + const productPage = new ProductPage(page); + await productPage.addProductToWishlist(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + }); + }); + + + test.fixme('Leave a product review (Test currently fails due to error on website)',{ tag: '@cold'}, async ({}) => { + // const productPage = new ProductPage(page); + // await productPage.leaveProductReview(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + }); + + test('Open_pictures_in_lightbox_and_scroll', async ({page}) => { + const productPage = new ProductPage(page); + await productPage.openLightboxAndScrollThrough(slugs.productPage.configurableProductSlug); + }); + + test('Change_number_of_reviews_shown_on_product_page', async ({page}) => { + const productPage = new ProductPage(page); + await productPage.changeReviewCountAndVerify(slugs.productPage.simpleProductSlug); + }); +}); diff --git a/dev/tests/e2e/base-tests/search.spec.ts b/dev/tests/e2e/base-tests/search.spec.ts new file mode 100644 index 00000000000..ccf88e5ba23 --- /dev/null +++ b/dev/tests/e2e/base-tests/search.spec.ts @@ -0,0 +1,33 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { UIReference, outcomeMarker, inputValues, slugs } from '@config'; + +import SearchPage from '@poms/frontend/search.page'; + +test.describe.fixme('Search functionality - needs t be adapted to OpenSearch', () => { + test('Search_query_returns_multiple_results', async ({ page }) => { + await page.goto(''); + const searchPage = new SearchPage(page); + await searchPage.search(inputValues.search.queryMultipleResults); + await expect(page).toHaveURL(new RegExp(slugs.search.resultsSlug)); + const results = page.locator(`${UIReference.categoryPage.productGridLocator} li`); + const resultCount = await results.count(); + expect(resultCount).toBeGreaterThan(1); + }); + + test('User_can_find_a_specific_product_and_navigate_to_its_page', async ({ page }) => { + await page.goto(''); + const searchPage = new SearchPage(page); + await searchPage.search(inputValues.search.querySpecificProduct); + // await expect(page).toHaveURL(`**${slugs.productPage.simpleProductSlug}`); + expect(page.url()).toEqual(expect.stringContaining(`${slugs.productPage.simpleProductSlug}`)); + }); + + test('No_results_message_is_shown_for_unknown_query', async ({ page }) => { + await page.goto(''); + const searchPage = new SearchPage(page); + await searchPage.search(inputValues.search.queryNoResults); + await expect(page.getByText(outcomeMarker.search.noResultsMessage)).toBeVisible(); + }); +}); diff --git a/dev/tests/e2e/base-tests/setup.spec.ts b/dev/tests/e2e/base-tests/setup.spec.ts new file mode 100644 index 00000000000..f04b86d7a54 --- /dev/null +++ b/dev/tests/e2e/base-tests/setup.spec.ts @@ -0,0 +1,214 @@ +// @ts-check + +/** + * Copyright elgentos. All rights reserved. + * https://elgentos.nl/ + * + * @fileOverview adjusts necessary settings and records for testing purposes. + */ + +import { test, expect } from '@playwright/test'; + +import { requireEnv } from '@utils/env.utils'; +import ApiClient from '@utils/apiClient.utils'; + +import { inputValues } from '@config'; + +import AdminLogin from '@poms/admin/adminlogin.page'; +import AdminMarketing from '@poms/admin/marketing.page'; + +/** + * Set variables we'll be using throughout the file. + */ +const magentoAdminUsername = requireEnv(`MAGENTO_ADMIN_USERNAME`); +const magentoAdminPassword = requireEnv(`MAGENTO_ADMIN_PASSWORD`); +let APIClient : ApiClient; + +// Set up an API Client +test.beforeAll(`Initialize API Client`, async() => { + APIClient = await new ApiClient().create(); +}); + +/** + * Disable the Login CAPTCHA to ensure Playwright can log in. + * + * @param page - Playwright Page instance (fixture) + * @param browserName - the name of the browser running the test. + */ +test('Disable_login_captcha_and_enable_multiple_login', { + tag: '@setup'}, async ({ page, browserName }) => { + + test.skip( browserName !== 'chromium', + `Disabling login captcha through Chromium. This is ${browserName}, therefore test is skipped.` + ); + + const adminLoginPage = new AdminLogin(page); + + await test.step(`Step: Login to admin environment`, async() => { + await adminLoginPage.loginAdmin(magentoAdminUsername, magentoAdminPassword); + }); + + await test.step(`Step: Disable login CAPTCHA`, async() => { + await adminLoginPage.navigateToStoreSettings(); + await adminLoginPage.disableLoginCaptcha(); + }); + + await test.step(`Step: Enable multiple admin login`, async() => { + await expect(async () => { + await expect(page.getByRole('link', {name: 'Customer Configuration'}), + `"Customer Configuration" under General section is visible.`).toBeVisible(); + }).toPass(); + + await adminLoginPage.enableMultipleAdminLogins(); + }); + +}); + +/** + * Set up test accounts through the Magento API + * + * @param browserName - used to identify the browser the test is running in. + * @param testInfo - Playwright class that allows annotations to the report and more. + */ +test(`Create_test_accounts`, { tag: ['@setup', '@api']}, async ({ browserName }, testInfo) => { + test.slow(); // Mark as slow to double test time. + + // Skip if not Chromium + test.skip( browserName !== 'chromium', `Accounts are made through API call - only one browser is required.`); + + /** + * Test step: create generic test accounts + */ + await test.step(`Creating accounts for general testing`, async() => { + // Start by checking if the accounts already exist + const allCustomers = await APIClient.get( + `/rest/V1/customers/search` + + `?searchCriteria[filterGroups][0][filters][0][field]=email` + + `&searchCriteria[filterGroups][0][filters][0][value]=%25playwright_user%25` + + `&searchCriteria[filterGroups][0][filters][0][conditionType]=like`); + const testAccountsPresent = allCustomers.items ?? []; + + // Check for test accounts, create them if not found + if(testAccountsPresent.length > 0) { + test.info().annotations.push({ + type: `test accounts found`, + description: `We found testing accounts. Please check if the following is correct: + ${JSON.stringify(testAccountsPresent, null, 2)}` + }); + } else { + for(let accountId = 0; accountId < 13; accountId++) { + const customerPayload = { + customer : { + email: `playwright_user_${accountId}@elgentos.nl`, + firstname: `${inputValues.account.firstName}`, + lastname: `${inputValues.account.lastName}` + }, + password: `${requireEnv('MAGENTO_ADMIN_PASSWORD')}` + }; + + // Send payload to database + const addCustomerResponse = await APIClient.post(`/rest/V1/customers`, customerPayload); + + // Annotate report with relevant info + test.info().annotations.push({ + type: `accounts created!`, + description: `The following accounts have been created: + ${JSON.stringify(addCustomerResponse, null, 2)}` + }); + } + } + }); +}); + +/** + * Set up coupon codes through the Magento API + * + * @param browserName - used to create the specific coupon code + */ +test(`Set_coupon_codes`, { + tag: ['@setup', '@api']}, async ({ browserName }, ) => { + + // TODO: Clean up code + // TODO: Move to marketing.page.ts + // TODO: Remove the use of the requireEnv(), since it's not necessary anymore. + + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const couponCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); + + // Find all coupon codes, then check if testing coupon exists + const couponCheckResponse = await APIClient.get(`/rest/V1/coupons/search?searchCriteria=all`); + const codePresent = couponCheckResponse.items.some((item: { code: string; }) => item.code === `${couponCode}`); + + // If coupon is present, check if it's enabled. + if(codePresent) { + // Retrieve sales rule + const coupon = couponCheckResponse.items.find((item: { code: string; }) => item.code === `${couponCode}`); + const ruleId = coupon.rule_id; + const rule = await APIClient.get(`/rest/V1/salesRules/${ruleId}`); + + // If not active, set to active. + if(!rule.is_active) { + rule.is_active = true; + const updateCoupon = await APIClient.put(`/rest/V1/salesRules/${ruleId}`, { rule: rule }); + + if(updateCoupon.is_active) { + test.info().annotations.push({type: 'Coupon notice', description: `Your code "${coupon.code}" was found, but we had to activate it manually.`}); + } + return; + } else { + // code is present and enabled. + test.info().annotations.push({type: 'Coupon notice', description: `Your code "${coupon.code}" was found. Active status: ${rule.is_active}.`}); + return; + } + + } else { + // Not present. Set coupon code, then check. + const rules = await APIClient.get(`/rest/V1/salesRules/search?searchCriteria=all`); + const websiteInfo = await APIClient.get(`/rest/V1/store/websites`); + const customerGroups = await APIClient.get(`/rest/V1/customerGroups/search?searchCriteria=all`); + let websiteIds: any[] = []; + let customerGroupsIds: any[] = []; + + websiteInfo.forEach((website: { name: string; id: any; }) => { + if(website.name !== 'admin') { + websiteIds.push(website.id); + } + }); + + customerGroups.items.forEach((customerGroup: { id: any; }) => { + customerGroupsIds.push(customerGroup.id); + }); + + const newRule = { + name : 'Test Coupon', + website_ids: websiteIds, + customer_group_ids: customerGroupsIds, + from_date: '2025-01-20', + uses_per_customer: 0, + is_active: true, + stop_rules_processing: true, + is_advanced: true, + sort_order: 0, + discount_amount: 10, + discount_step: 0, + apply_to_shipping: false, + times_used: 0, + is_rss: true, + coupon_type: 'SPECIFIC_COUPON', + use_auto_generation: false, + uses_per_coupon: 0 + }; + + const newCouponRule = await APIClient.post(`/rest/V1/salesRules`, {rule: newRule}); + + const couponAPIJSON = { + rule_id: newCouponRule.rule_id, + code: couponCode, + times_used: 0, + is_primary: true + }; + + const createNewCoupon = await APIClient.post(`/rest/V1/coupons`, {coupon: couponAPIJSON}); + test.info().annotations.push({type: `Coupon Created`, description: `Created coupon: ${JSON.stringify(createNewCoupon)}`}); + } +}); diff --git a/dev/tests/e2e/base-tests/shoppingcart.spec.ts b/dev/tests/e2e/base-tests/shoppingcart.spec.ts new file mode 100644 index 00000000000..c4d379b8f2f --- /dev/null +++ b/dev/tests/e2e/base-tests/shoppingcart.spec.ts @@ -0,0 +1,246 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { UIReference, slugs, outcomeMarker } from '@config'; + +import CartPage from '@poms/frontend/shoppingcart.page'; +import LoginPage from '@poms/frontend/login.page'; +import ProductPage from '@poms/frontend/product.page'; +import { requireEnv } from '@utils/env.utils'; +import NotificationValidatorUtils from '@utils/notificationValidator.utils'; + +test.describe('Cart functionalities (guest)', () => { + /** + * @feature BeforeEach runs before each test in this group. + * @scenario Add a product to the cart and confirm it's there. + * @given I am on any page + * @when I navigate to a (simple) product page + * @and I add it to my cart + * @then I should see a notification + * @when I click the cart in the main menu + * @then the minicart should become visible + * @and I should see the product in the minicart + */ + test.beforeEach(async ({ page }, testInfo) => { + const productPage = new ProductPage(page); + await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + + const productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} ${UIReference.productPage.simpleProductTitle}`; + const notificationValidator = new NotificationValidatorUtils(page, testInfo); + await notificationValidator.validate(productAddedNotification); + + await page.goto(slugs.cart.cartSlug); + }); + + /** + * @feature Product can be added to cart + * @scenario User adds a product to their cart + * @given I have added a product to my cart + * @and I am on the cart page + * @then I should see the name of the product in my cart + */ + test('Add_product_to_cart',{ tag: ['@cart', '@cold'],}, async ({page}) => { + await expect(page.getByRole('heading').getByRole('link', {name: UIReference.productPage.simpleProductTitle}), `Product is visible in cart`).toBeVisible(); + }); + + /** + * @feature Product permanence after login + * @scenario A product added to the cart should still be there after user has logged in + * @given I have a product in my cart + * @when I log in + * @then I should still have that product in my cart + */ + test('Product_remains_in_cart_after_login',{ tag: ['@cart', '@account', '@hot']}, async ({page, browserName}) => { + await test.step('Add another product to cart', async () =>{ + const productpage = new ProductPage(page); + await page.goto(slugs.productPage.secondSimpleProductSlug); + await productpage.addSimpleProductToCart(UIReference.productPage.secondSimpleProducTitle, slugs.productPage.secondSimpleProductSlug); + }); + + await test.step('Log in with account', async () =>{ + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const loginPage = new LoginPage(page); + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + await loginPage.login(emailInputValue, passwordInputValue); + }); + + await page.goto(slugs.cart.cartSlug); + await expect(page.getByRole('heading').getByRole('link', { name: UIReference.productPage.simpleProductTitle }),`${UIReference.productPage.simpleProductTitle} should still be in cart`).toBeVisible(); + await expect(page.getByRole('heading').getByRole('link', { name: UIReference.productPage.secondSimpleProducTitle }),`${UIReference.productPage.secondSimpleProducTitle} should still be in cart`).toBeVisible(); + }); + + /** + * @feature Remove product from cart + * @scenario User has added a product and wants to remove it from the cart page + * @given I have added a product to my cart + * @and I am on the cart page + * @when I click the delete button + * @then I should see a notification that the product has been removed from my cart + * @and I should no longer see the product in my cart + */ + test('Remove_product_from_cart',{ tag: ['@cart','@cold'],}, async ({page}) => { + const cart = new CartPage(page); + await cart.removeProduct(UIReference.productPage.simpleProductTitle); + }); + + /** + * @feature Change quantity of products in cart + * @scenario User has added a product and changes the quantity + * @given I have a product in my cart + * @and I am on the cart page + * @when I change the quantity of the product + * @and I click the update button + * @then the quantity field should have the new amount + * @and the subtotal/grand total should update + */ + test('Change_product_quantity_in_cart',{ tag: ['@cart', '@cold'],}, async ({page}) => { + const cart = new CartPage(page); + await cart.changeProductQuantity('2'); + }); + + /** + * @feature Discount Code + * @scenario User adds a discount code to their cart + * @given I have a product in my cart + * @and I am on my cart page + * @when I click on the 'add discount code' button + * @then I fill in a code + * @and I click on 'apply code' + * @then I should see a confirmation that my code has been added + * @and the code should be visible in the cart + * @and a discount should be applied to the product + */ + test('Add_coupon_code_in_cart',{ tag: ['@cart', '@coupon-code', '@cold']}, async ({page, browserName}) => { + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const cart = new CartPage(page); + const discountCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); + + await cart.applyDiscountCode(discountCode); + }); + + /** + * @feature Remove discount code from cart + * @scenario User has added a discount code, then removes it + * @given I have a product in my cart + * @and I am on my cart page + * @when I add a discount code + * @then I should see a notification + * @and the code should be visible in the cart + * @and a discount should be applied to a product + * @when I click the 'cancel coupon' button + * @then I should see a notification the discount has been removed + * @and the discount should no longer be visible. + */ + test('Remove_coupon_code_from_cart',{ tag: ['@cart', '@coupon-code', '@cold'] }, async ({page, browserName}) => { + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const cart = new CartPage(page); + const discountCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); + + await cart.applyDiscountCode(discountCode); + await cart.removeDiscountCode(); + }); + + /** + * @feature Incorrect discount code check + * @scenario The user provides an incorrect discount code, the system should reflect that + * @given I have a product in my cart + * @and I am on the cart page + * @when I enter a wrong discount code + * @then I should get a notification that the code did not work. + */ + + test('Invalid_coupon_code_is_rejected',{ tag: ['@cart', '@coupon-code', '@cold'] }, async ({page}) => { + const cart = new CartPage(page); + await cart.enterWrongCouponCode("Incorrect Coupon Code"); + }); +}) + +test.describe('Price checking tests', () => { + + /** + * @feature Simple Product price/amount check from PDP to Checkout + * @given none + * @when I go to a (simple) product page + * @and I add one or more to my cart + * @when I go to the checkout + * @then the amount of the product should be the same + * @and the price in the checkout should equal the price of the product * the amount of the product + */ + test('Simple_product_cart_data_consistent_from_PDP_to_checkout',{ tag: ['@cart-price-check', '@cold']}, async ({page}) => { + let productPagePrice: string; + let productPageAmount: string; + let checkoutProductDetails: string[]; + + const cart = new CartPage(page); + + await test.step('Step: Add simple product to cart', async () =>{ + const productPage = new ProductPage(page); + await page.goto(slugs.productPage.simpleProductSlug); + // set quantity to 2 so we can see that the math works + await page.getByLabel(UIReference.productPage.quantityFieldLabel).fill('2'); + + productPagePrice = await page.locator(UIReference.productPage.simpleProductPrice).innerText(); + productPageAmount = await page.getByLabel(UIReference.productPage.quantityFieldLabel).inputValue(); + await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug, '2'); + + }); + + await test.step('Step: go to checkout, get values', async () =>{ + await page.goto(slugs.checkout.checkoutSlug); + await page.waitForLoadState(); + + // returns productPriceInCheckout and productQuantityInCheckout + checkoutProductDetails = await cart.getCheckoutValues(UIReference.productPage.simpleProductTitle, productPagePrice, productPageAmount); + }); + + await test.step('Step: Calculate and check expectations', async () =>{ + await cart.calculateProductPricesAndCompare(productPagePrice, productPageAmount, checkoutProductDetails[0], checkoutProductDetails[1]); + }); + + }); + + /** + * @feature Configurable Product price/amount check from PDP to Checkout + * @given none + * @when I go to a (configurable) product page + * @and I add one or more to my cart + * @when I go to the checkout + * @then the amount of the product should be the same + * @and the price in the checkout should equal the price of the product * the amount of the product + */ + test('Configurable_product_cart_data_consistent_from_PDP_to_checkout',{ tag: ['@cart-price-check', '@cold']}, async ({page}) => { + var productPagePrice: string; + var productPageAmount: string; + var checkoutProductDetails: string[]; + + const cart = new CartPage(page); + + await test.step('Step: Add configurable product to cart', async () =>{ + const productPage = new ProductPage(page); + // Navigate to the configurable product page so we can retrieve price and amount before adding it to cart + await page.goto(slugs.productPage.configurableProductSlug); + // set quantity to 2 so we can see that the math works + await page.getByLabel('Quantity').fill('2'); + + productPagePrice = await page.locator(UIReference.productPage.simpleProductPrice).innerText(); + productPageAmount = await page.getByLabel(UIReference.productPage.quantityFieldLabel).inputValue(); + await productPage.addConfigurableProductToCart(UIReference.productPage.configurableProductTitle, slugs.productPage.configurableProductSlug, '2'); + + }); + + await test.step('Step: go to checkout, get values', async () =>{ + await page.goto(slugs.checkout.checkoutSlug); + await page.waitForLoadState(); + + // returns productPriceInCheckout and productQuantityInCheckout + checkoutProductDetails = await cart.getCheckoutValues(UIReference.productPage.configurableProductTitle, productPagePrice, productPageAmount); + }); + + await test.step('Step: Calculate and check expectations', async () =>{ + await cart.calculateProductPricesAndCompare(productPagePrice, productPageAmount, checkoutProductDetails[0], checkoutProductDetails[1]); + }); + + }); +}); diff --git a/dev/tests/e2e/base-tests/types/magewire.d.ts b/dev/tests/e2e/base-tests/types/magewire.d.ts new file mode 100644 index 00000000000..346a10ac35f --- /dev/null +++ b/dev/tests/e2e/base-tests/types/magewire.d.ts @@ -0,0 +1,8 @@ +// @ts-check + +interface Window { + magewire?: { + processing: boolean; + [key: string]: any; + }; +} diff --git a/dev/tests/e2e/base-tests/utils/apiClient.utils.ts b/dev/tests/e2e/base-tests/utils/apiClient.utils.ts new file mode 100644 index 00000000000..83b568b4434 --- /dev/null +++ b/dev/tests/e2e/base-tests/utils/apiClient.utils.ts @@ -0,0 +1,157 @@ +// @ts-check + +import { request, expect, APIRequestContext, APIResponse } from '@playwright/test'; +import { requireEnv } from '@utils/env.utils'; + +class ApiClient { + private context!: APIRequestContext; + private token: string | undefined; + private tokenExpiry: number | undefined; + + constructor() {} + + /** + * Initializes the ApiClient by ensuring a valid token and setting up the request context. + * @returns {Promise} A Promise that resolves to an instance of ApiClient. + */ + async create(): Promise { + await this.ensureToken(); + + this.context = await request.newContext({ + baseURL: requireEnv('PLAYWRIGHT_BASE_URL'), + extraHTTPHeaders: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.token}`, + }, + }); + + return this; + } + + /** + * Ensures the API token is valid, refreshing it if expired or absent. + * @private + * @returns {Promise} + */ + private async ensureToken(): Promise { + if (!this.token || this.isTokenExpired()) { + this.token = await this.refreshIntegrationToken(); + } + } + + /** + * Fetches a new API token from the server and sets the expiry time. + * @private + * @returns {Promise} A Promise that resolves to the token string. + * @throws {Error} If token retrieval fails. + */ + private async refreshIntegrationToken(): Promise { + const tempContext = await request.newContext({ + baseURL: requireEnv('PLAYWRIGHT_BASE_URL'), + extraHTTPHeaders: { + 'Content-Type': 'application/json', + }, + }); + + const response = await tempContext.post('/rest/V1/integration/admin/token', { + data: { + username: requireEnv('MAGENTO_ADMIN_USERNAME'), + password: requireEnv('MAGENTO_ADMIN_PASSWORD'), + }, + }); + + if (!response.ok()) { + const errorBody = await response.text(); + await tempContext.dispose(); + throw new Error(`Failed to obtain integration token: ${response.status()} ${errorBody}`); + } + + const token = await response.json(); + const expiresHeader = response.headers()['expires']; + if (expiresHeader) { + this.tokenExpiry = new Date(expiresHeader).getTime(); + } else { + this.tokenExpiry = Date.now() + (3600 * 1000); + } + + await tempContext.dispose(); + return token; + } + + /** + * Determines if the current token is expired. + * @private + * @returns {boolean} True if the token is expired, otherwise false. + */ + private isTokenExpired(): boolean { + return !this.tokenExpiry || Date.now() >= this.tokenExpiry; + } + + /** + * Performs a GET request to the specified URL. + * @param {string} url The endpoint URL to send the request to. + * @returns {Promise} A Promise that resolves to the response JSON. + */ + async get(url: string): Promise { + const response = await this.context.get(url); + return this.handleResponse(response); + } + + /** + * Performs a POST request with the given payload to the specified URL. + * @param {string} url The endpoint URL to send the request to. + * @param {Record} payload The data payload to send with the request. + * @returns {Promise} A Promise that resolves to the response JSON. + * @throws {Error} If the response indicates failure. + */ + async post(url: string, payload: Record): Promise { + const response = await this.context.post(url, { data: payload }); + return this.handleResponse(response); + } + + /** + * Performs a PUT request with the given payload to the specified URL. + * @param {string} url The endpoint URL to send the request to. + * @param {Record} payload The data payload to send with the request. + * @returns {Promise} A Promise that resolves to the response JSON. + * @throws {Error} If the response indicates failure. + */ + async put(url: string, payload: Record): Promise { + const response = await this.context.put(url, { data: payload }); + return this.handleResponse(response); + } + + /** + * Performs a DELETE request to the specified URL. + * @param {string} url The endpoint URL to send the request to. + * @returns {Promise} A Promise indicating successful deletion. + */ + async delete(url: string): Promise { + const response = await this.context.delete(url); + return this.handleResponse(response); + } + + /** + * Handles an API response, checking for success and parsing the JSON body. + * @param {APIResponse} response The response object to handle. + * @returns {Promise} A Promise that resolves to the response JSON. + * @throws {Error} If the response is not successful. + */ + async handleResponse(response: APIResponse): Promise { + if (!response.ok()) { + const body = await response.text(); + throw new Error(`API call failed [${response.status()}]: ${body}`); + } + return await response.json(); + } + + /** + * Disposes of the current request context. + * @returns {Promise} + */ + async dispose(): Promise { + await this.context.dispose(); + } +} + +export default ApiClient; diff --git a/dev/tests/e2e/base-tests/utils/env.utils.ts b/dev/tests/e2e/base-tests/utils/env.utils.ts new file mode 100644 index 00000000000..647c7590fe6 --- /dev/null +++ b/dev/tests/e2e/base-tests/utils/env.utils.ts @@ -0,0 +1,14 @@ +// @ts-check + +/** + * Utility to retrieve required environment variables. + * Throws an error when the variable is not set. + */ +export function requireEnv(varName: string): string { + const value = process.env[varName]; + if (!value) { + throw new Error(`${varName} is not defined in the .env file.`); + } + return value; +} + diff --git a/dev/tests/e2e/base-tests/utils/fixtures.utils.ts b/dev/tests/e2e/base-tests/utils/fixtures.utils.ts new file mode 100644 index 00000000000..e80b273ca7a --- /dev/null +++ b/dev/tests/e2e/base-tests/utils/fixtures.utils.ts @@ -0,0 +1,101 @@ +// @ts-check + +/** + * Copyright elgentos. All rights reserved. + * https://elgentos.nl/ + * @fileOverview override the StorageState fixture to ensure we can create accounts per worker + */ + +import { test as baseTest, expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import {requireEnv} from "@utils/env.utils"; +import {slugs, UIReference} from "@config"; + +export * from '@playwright/test'; +export const test = baseTest.extend<{}, { workerStorageState: string }>({ + // Use the same storage state for all tests in this worker. + storageState: ({ workerStorageState }, use) => use(workerStorageState), + + // Authenticate once per worker with a worker-scoped fixture. + workerStorageState: [async ({ browser }, use) => { + // Use parallelIndex as a unique identifier for each worker. + const id = test.info().parallelIndex; + const fileName = path.resolve(__dirname, `../../.auth/worker_${id}.json`); + + // Check if the user is actually logged in + const userIsLoggedIn = async (storageState?: string): Promise => { + const context = await browser.newContext({ + baseURL: requireEnv('PLAYWRIGHT_BASE_URL'), + storageState, + ignoreHTTPSErrors: true, + }); + + const page = await context.newPage(); + await page.goto(slugs.account.accountOverviewSlug, { waitUntil: 'domcontentloaded' }); + + const loggedIn = + !page.url().includes(slugs.account.loginSlug); + + await context.close(); + // console.log(`Is user considered logged in? ${loggedIn}`); + return loggedIn; + } + + // If storage file exists *and* user is considered logged in, you can use! + if (fs.existsSync(fileName) && await userIsLoggedIn(fileName)) { + // console.log(`authentication file exists, and user is considered logged in!`); + await use(fileName); + return; + } + + // Important: make sure we authenticate in a clean environment by unsetting storage state. + const page = await browser.newPage({ + storageState: undefined, + baseURL: requireEnv(`PLAYWRIGHT_BASE_URL`), + ignoreHTTPSErrors: true, + }); + + // Acquire a unique account, for example create a new one. + // Alternatively, you can have a list of pre-created accounts for testing. + // Make sure that accounts are unique, so that multiple team members + // can run tests at the same time without interference. + const account = { + 'username': `playwright_user_${id}@elgentos.nl`, + 'password': requireEnv(`MAGENTO_EXISTING_ACCOUNT_PASSWORD`) + }; + + const emailField = page.getByRole('textbox', {name: UIReference.credentials.emailFieldLabel, exact: true}); + const pwField = page.getByRole('textbox', {name: UIReference.credentials.passwordFieldLabel}); + const loginButton = page.getByRole('button', { name: UIReference.credentials.loginButtonLabel }); + + // Perform authentication steps. Replace these actions with your own. + await page.goto(slugs.account.loginSlug); + await emailField.waitFor(); + + await emailField.fill(account.username); + await pwField.fill(account.password); + await loginButton.click(); + + // Wait until the page receives the cookies. + // We do this by waiting for the page to be done loading. This should navigate to the customer account page. + await page.waitForURL('**/', {waitUntil: 'networkidle'}); + + // Confirm by checking page.url() returns https://hyva-demo.magento2.localhost/default/customer/account/ + // console.log(page.url()); + + await expect(async () => { + await expect( + page.locator(UIReference.general.headingOneLocator), + `Homepage has the expected text in title` + ).toContainText(UIReference.titles.accountHeading); + }).toPass(); + + // End of authentication steps. + + await page.context().storageState({ path: fileName }); + await page.close(); + console.log(`${fileName} has been newly built.`); + await use(fileName); + }, { scope: 'worker' }], +}); diff --git a/dev/tests/e2e/base-tests/utils/logger/Logger.ts b/dev/tests/e2e/base-tests/utils/logger/Logger.ts new file mode 100644 index 00000000000..5446ff05b9d --- /dev/null +++ b/dev/tests/e2e/base-tests/utils/logger/Logger.ts @@ -0,0 +1,67 @@ +// @ts-check + +export class Logger { + private readonly context: string; + private readonly isDebug: boolean; + + constructor(context: string) { + this.context = context; + this.isDebug = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'; + } + + private formatMessage(level: string, args: unknown[]): string { + const prefix = `[${this.context}] [${level.toUpperCase()}]`; + const message = args.map(arg => { + try { + return typeof arg === 'string' ? arg : JSON.stringify(arg); + } catch { + return '[Unserializable]'; + } + }).join(' '); + return `${prefix} ${message}\n`; + } + + private write(level: string, args: unknown[]): void { + if (!this.isDebug && level === 'log') { + return; + } + const msg = this.formatMessage(level, args); + if (typeof process !== 'undefined' && process.stdout?.write) { + if (level === 'error') { + process.stderr.write(msg); + } else { + process.stdout.write(msg); + } + } else { + // eslint-disable-next-line no-console + switch (level) { + case 'log': + case 'info': + console.info(msg.trim()); + break; + case 'warn': + console.warn(msg.trim()); + break; + case 'error': + console.error(msg.trim()); + break; + } + } + } + + public log(...args: unknown[]): void { + this.write('log', args); + } + + public info(...args: unknown[]): void { + this.write('info', args); + } + + public warn(...args: unknown[]): void { + this.write('warn', args); + } + + public error(...args: unknown[]): void { + this.write('error', args); + } +} diff --git a/dev/tests/e2e/base-tests/utils/logger/factory.ts b/dev/tests/e2e/base-tests/utils/logger/factory.ts new file mode 100644 index 00000000000..a5936a4691e --- /dev/null +++ b/dev/tests/e2e/base-tests/utils/logger/factory.ts @@ -0,0 +1,7 @@ +// @ts-check + +import { Logger } from './Logger'; + +export function createLogger(context: string): Logger { + return new Logger(context); +} diff --git a/dev/tests/e2e/base-tests/utils/logger/index.ts b/dev/tests/e2e/base-tests/utils/logger/index.ts new file mode 100644 index 00000000000..86c314c5310 --- /dev/null +++ b/dev/tests/e2e/base-tests/utils/logger/index.ts @@ -0,0 +1,4 @@ +// @ts-check + +export { Logger } from './Logger'; +export { createLogger } from './factory'; \ No newline at end of file diff --git a/dev/tests/e2e/base-tests/utils/magewire.utils.ts b/dev/tests/e2e/base-tests/utils/magewire.utils.ts new file mode 100644 index 00000000000..750500c5eb0 --- /dev/null +++ b/dev/tests/e2e/base-tests/utils/magewire.utils.ts @@ -0,0 +1,97 @@ +// @ts-check + +import {expect, Page} from '@playwright/test'; + +class MagewireUtils { + + protected page: Page; + private activeRequests: Set = new Set(); + + constructor(page: Page) { + this.page = page; + } + + /** + * Sets up request/response monitoring for Magewire traffic. + * Must be called before Magewire activity starts (e.g. in beforeEach). + */ + startMonitoring(): void { + const handleMagewireTraffic = (type: 'add' | 'delete') => (event: { url(): string }) => { + const url = event.url(); + if (this.isMagewireRequest(url)) { + this.activeRequests[type](url); + } + }; + + this.page.on('request', handleMagewireTraffic('add')); + this.page.on('response', handleMagewireTraffic('delete')); + this.page.on('requestfailed', handleMagewireTraffic('delete')); + } + + /** + * Waits until all Magewire network requests are completed. + */ + async waitForMagewireRequests(): Promise { + const settlingTime = 100; // ms to wait after last request seen + const maxWaitTime = 10000; // total timeout + const checkInterval = 50; // interval to check active requests + + const start = Date.now(); + + while (Date.now() - start <= maxWaitTime) { + if (this.activeRequests.size === 0) { + // Wait a little to ensure no new requests are triggered + await this.page.waitForTimeout(settlingTime); + if (this.activeRequests.size === 0) { + await this.waitForMagewireDomIdle(); + return; + } + } + + await this.page.waitForTimeout(checkInterval); + } + + throw new Error('[Magewire] Timeout: Still pending requests after wait'); + } + + // private async waitForMagewireDomIdle(): Promise { + // // look for the magewire pop-up + // // const element = this.page.locator('.magewire.messenger'); + // const element = this.page.locator('#magewire-loader-notifications > div'); + // + // // LocatorHandler will keep looking for pop-up + // await this.page.addLocatorHandler(element, async() => { + // // Keep retrying, waiting for element to be hidden. + // await expect(async () => { + // // await expect(element).toBeHidden(); + // await expect(element).toHaveCount(0); + // }).toPass(); + // }, {noWaitAfter: true}) + // } + + private async waitForMagewireDomIdle(): Promise { + // 1. Check of de messenger height 0px is + // await this.page.waitForFunction(() => { + // // const element = document.querySelector('#magewire-loader-notifications > div'); + + // // magewire element "Saving Shipping Method" + // //#magewire-loader-notifications > div > div > div + + // const element = document.querySelector('.magewire\\.messenger'); + // return element && getComputedStyle(element).height === '0px'; + // }, { timeout: 30000 }); + + // 2. Check if there is no processing ongoing + await this.page.waitForFunction(() => { + return !(window.magewire && (window.magewire as any).processing); + }, { timeout: 30000 }); + + await this.page.waitForTimeout(500); + } + + private isMagewireRequest(url: string): boolean { + return url.includes('/magewire/message'); + } +} + +export default MagewireUtils; diff --git a/dev/tests/e2e/base-tests/utils/notificationValidator.utils.ts b/dev/tests/e2e/base-tests/utils/notificationValidator.utils.ts new file mode 100644 index 00000000000..d17c6f0745b --- /dev/null +++ b/dev/tests/e2e/base-tests/utils/notificationValidator.utils.ts @@ -0,0 +1,56 @@ +// @ts-check + +import { expect, Page, TestInfo } from "@playwright/test"; +import { UIReference } from '@config'; + +class NotificationValidatorUtils { + + private page : Page; + private testInfo: TestInfo; + + constructor(page: Page, testInfo: TestInfo) { + this.page = page; + this.testInfo = testInfo; + } + + /** + * @param value - the expected notification + */ + async validate(value: string) { + const messages = await this.page.locator(UIReference.general.messageLocator).all(); + let iteration = messages.length; + + for (const memo of messages) { + let reportAnnotation = `Action was successful, but notification text could not be extracted.`; + + // wait for item to be visible + await memo.waitFor({state: 'visible'}); + let msgContent = await memo.textContent(); + + if(msgContent !== null) { + reportAnnotation = msgContent.trim(); + + if (msgContent.includes(value)) { + // message equals expected message! + // Push to report... + this.testInfo.annotations.push({ type: `Validator Note`, description: msgContent }); + + // ... then confirm + expect(msgContent, `Message should be ${value}`).toEqual(expect.stringContaining(value)); + + } else { + if(!--iteration) { + // the message did not equal our value, and we've reached the last item in list. + // Push to report... + this.testInfo.annotations.push({ type: `Validator Note`, description: msgContent }); + + // ... then confirm + expect(msgContent, `Message should be ${value}`).toEqual(expect.stringContaining(value)); + } + } + } + } + } +} + +export default NotificationValidatorUtils; diff --git a/dev/tests/e2e/build.js b/dev/tests/e2e/build.js new file mode 100755 index 00000000000..7b8fc1e5a56 --- /dev/null +++ b/dev/tests/e2e/build.js @@ -0,0 +1,104 @@ +const fs = require('fs'); +const path = require('path'); + +class Build { + + pathToBaseDir = '../../../'; // default: when installed via npm + tempDirTests = 'base-tests'; + exampleFileName = '.example'; + + constructor() { + const isLocalDev = fs.existsSync(path.resolve(__dirname, '.git')); + + if (isLocalDev) { + this.pathToBaseDir = './'; // we're in the root of the dev repo + } + + this.copyExampleFiles(); + this.copyTestsToTempFolder(); + this.createNewTestsFolderForCustomTests(); + } + + /** + * @feature Copy config example files + * @scenario Copy all `.example` files from the current directory to the root directory. + * @given I have `.example` files in this directory + * @when I run the Build script + * @then The `.example` files should be copied to the root directory without the `.example` extension + * @and Existing destination files should NOT be overwritten, but skipped + */ + copyExampleFiles() { + // const exampleFiles = new Set(); + const exampleFiles = new Set(fs.readdirSync(__dirname).filter(file => file.includes(this.exampleFileName))); + + for (const file of exampleFiles) { + // destination will be created or overwritten by default. + const sourceFile = './' + file; + const destFile = this.pathToBaseDir + file.replace(this.exampleFileName, ''); + + try { + fs.copyFileSync(sourceFile, destFile, fs.constants.COPYFILE_EXCL); + console.log(`${path.basename(destFile)} was copied to destination`); + } catch (err) { + if (err.code === 'EEXIST') { + console.log(`${path.basename(destFile)} already exists, skipping copy.`); + } else { + throw err; + } + } + } + } + + /** + * @feature Copy base test files + * @scenario Prepare test suite by copying `tests/` to the root-level `base-tests/` folder. + * @given There is a `tests/` folder in the package directory + * @when I run the Build script + * @and A `base-tests/` folder already exists in the root + * @then The existing `base-tests/` folder should be removed + * @and A fresh copy of `tests/` should be placed in `../../../base-tests` + */ + copyTestsToTempFolder() { + + const sourceDir = path.resolve(__dirname, 'tests'); + const targetDir = path.resolve(__dirname, this.pathToBaseDir, this.tempDirTests); + + try { + if (fs.existsSync(targetDir)) { + fs.rmSync(targetDir, {recursive: true, force: true}); + } + + fs.cpSync(sourceDir, targetDir, {recursive: true}); + if (process.env.CI === 'true') { + fs.rmSync(sourceDir, {recursive: true, force: true}); + } + console.log(`Copied tests from ${sourceDir} to ${targetDir}`); + } catch (err) { + console.error('Error copying test directory:', err); + } + } + + /** + * @feature Create tests directory + * @scenario Ensure the `tests/` directory exists at the project root level. + * @given There is no `tests/` directory at the project root + * @when I run the `createNewTestsFolderForCustomTests` function + * @then A new `tests/` directory should be created at `../../../tests` + * @and A log message "Created tests directory: " should be output + * @given The `tests/` directory already exists at the project root + * @when I run the `createNewTestsFolderForCustomTests` function + * @then No new directory should be created + * @and A log message "Tests directory already exists: " should be output + */ + createNewTestsFolderForCustomTests() { + const testsDir = path.resolve(__dirname, this.pathToBaseDir, 'tests'); + if (!fs.existsSync(testsDir)) { + fs.mkdirSync(testsDir); + console.log(`Created tests directory: ${testsDir}`); + } else { + console.log(`Tests directory already exists: ${testsDir}`); + } + } +} + +new Build(); diff --git a/dev/tests/e2e/bypass-captcha.config.example.ts b/dev/tests/e2e/bypass-captcha.config.example.ts new file mode 100644 index 00000000000..ce6c6a25e50 --- /dev/null +++ b/dev/tests/e2e/bypass-captcha.config.example.ts @@ -0,0 +1,48 @@ +// @ts-check + +/** + * This file is used to set up the CAPTCHA bypass for your tests. + * It will set the global cookie to bypass CAPTCHA for Magento 2. + * See: https://github.com/elgentos/magento2-bypass-captcha-cookie + * + */ + +import { FullConfig } from '@playwright/test'; +import * as playwright from 'playwright'; +import dotenv from 'dotenv'; + +dotenv.config(); + +async function globalSetup(config: FullConfig) { + const bypassCaptcha = process.env.CAPTCHA_BYPASS === 'true'; + + for (const project of config.projects) { + const { storageState, browserName = 'chromium' } = project.use || {}; + if (storageState) { + const browserType = playwright[browserName]; + const browser = await browserType.launch(); + const context = await browser.newContext(); + + if (bypassCaptcha) { + // Set the global cookie to bypass CAPTCHA + await context.addCookies([{ + name: 'disable_captcha', // this cookie will be read by 'magento2-bypass-captcha-cookie' module. + value: '', // Fill with generated token. + domain: 'hyva-demo.elgentos.io', // Replace with your domain + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Lax', + }]); + console.log(`CAPTCHA bypass enabled for browser: ${project.name}`); + } else { + // Do nothing. + } + + await context.storageState({ path: `./auth-storage/${project.name}-storage-state.json` }); + await browser.close(); + } + } +} + +export default globalSetup; diff --git a/dev/tests/e2e/bypass-captcha.config.ts b/dev/tests/e2e/bypass-captcha.config.ts new file mode 100644 index 00000000000..ce6c6a25e50 --- /dev/null +++ b/dev/tests/e2e/bypass-captcha.config.ts @@ -0,0 +1,48 @@ +// @ts-check + +/** + * This file is used to set up the CAPTCHA bypass for your tests. + * It will set the global cookie to bypass CAPTCHA for Magento 2. + * See: https://github.com/elgentos/magento2-bypass-captcha-cookie + * + */ + +import { FullConfig } from '@playwright/test'; +import * as playwright from 'playwright'; +import dotenv from 'dotenv'; + +dotenv.config(); + +async function globalSetup(config: FullConfig) { + const bypassCaptcha = process.env.CAPTCHA_BYPASS === 'true'; + + for (const project of config.projects) { + const { storageState, browserName = 'chromium' } = project.use || {}; + if (storageState) { + const browserType = playwright[browserName]; + const browser = await browserType.launch(); + const context = await browser.newContext(); + + if (bypassCaptcha) { + // Set the global cookie to bypass CAPTCHA + await context.addCookies([{ + name: 'disable_captcha', // this cookie will be read by 'magento2-bypass-captcha-cookie' module. + value: '', // Fill with generated token. + domain: 'hyva-demo.elgentos.io', // Replace with your domain + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Lax', + }]); + console.log(`CAPTCHA bypass enabled for browser: ${project.name}`); + } else { + // Do nothing. + } + + await context.storageState({ path: `./auth-storage/${project.name}-storage-state.json` }); + await browser.close(); + } + } +} + +export default globalSetup; diff --git a/dev/tests/e2e/install.js b/dev/tests/e2e/install.js new file mode 100644 index 00000000000..589a76bd6b1 --- /dev/null +++ b/dev/tests/e2e/install.js @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const { execSync } = require('child_process'); + +class Install { + + rl = ''; + currentUser = ''; + isCi = false; + useDefaults = false; + pathToMagentoRootGitignore = '../../../../../../../'; // default: when installed via npm + envVars = {}; + + rulesToAddToIgnore = [ + '# playwright', + '/app/design/frontend///web/playwright/*', + '!/app/design/frontend///web/playwright/tests/', + '!/app/design/frontend///web/playwright/package.json', + '!/app/design/frontend///web/playwright/package-lock.json' + ] + + constructor() { + this.useDefaults = true + this.isCi = process.env.CI === 'true'; + this.currentUser = execSync('whoami').toString().trim(); + const isLocalDev = fs.existsSync(path.resolve(__dirname, '.git')); + + if (isLocalDev) { + this.pathToMagentoRootGitignore = './'; // we're in the root of the dev repo + } + + this.envVars = { + 'PLAYWRIGHT_BASE_URL': { default: 'https://hyva-demo.elgentos.io/' }, + 'PLAYWRIGHT_PRODUCTION_URL': { default: 'https://hyva-demo.elgentos.io/' }, + 'PLAYWRIGHT_REVIEW_URL': { default: 'https://hyva-demo.elgentos.io/' }, + 'MAGENTO_ADMIN_SLUG': { default: 'admin' }, + 'MAGENTO_ADMIN_USERNAME': { default: this.currentUser }, + 'MAGENTO_ADMIN_PASSWORD': { default: 'Test1234!' }, + 'MAGENTO_THEME_LOCALE': { default: 'nl_NL' }, + 'MAGENTO_NEW_ACCOUNT_PASSWORD': { default: 'NewTest1234!' }, + 'MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM': { default: 'user-CHROMIUM@elgentos.nl' }, + 'MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX': { default: 'user-FIREFOX@elgentos.nl' }, + 'MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT': { default: 'user-WEBKIT@elgentos.nl' }, + 'MAGENTO_EXISTING_ACCOUNT_PASSWORD': { default: 'Test1234!' }, + 'MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD': { default: 'AanpassenKan@0212' }, + 'MAGENTO_COUPON_CODE_CHROMIUM': { default: 'CHROMIUM321' }, + 'MAGENTO_COUPON_CODE_FIREFOX': { default: 'FIREFOX321' }, + 'MAGENTO_COUPON_CODE_WEBKIT': { default: 'WEBKIT321' } + } + + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + this.init(); + } + + async init() { + await this.setEnvVariables(); + await this.appendToGitIgnore(); + + console.log('\nInstallation completed successfully!'); + console.log('\nFor more information, please visit:'); + console.log('https://wiki.elgentos.nl/doc/stappenplan-testing-suite-implementeren-voor-klanten-hCGe4hVQvN'); + + // Close rl when no questions are asked + this.rl.close(); + } + + async askQuestion(query) { + return new Promise((resolve) => this.rl.question(query, resolve)) + } + + async setEnvVariables() { + // Check if user + if (!this.isCi) { + const initialAnswer = await this.askQuestion('Do you want to customize environment variables? (y/N): '); + this.useDefaults = initialAnswer.trim().toLowerCase() !== 'y'; + } + + if (this.isCi && fs.existsSync('.env')) { + console.log('Using existing .env'); + return; + } + + // Read and update .env file + const envPath = path.join('.env'); + let envContent = ''; + + for (const [key, value] of Object.entries(this.envVars)) { + let userInput = ''; + if (!this.isCi && !this.useDefaults) { + userInput = await this.askQuestion(`Enter ${ key } (default: ${ value.default }): `); + } + envContent += `${ key }=${ userInput || value.default }\n`; + } + + fs.writeFileSync(envPath, envContent); + } + + async appendToGitIgnore() { + if (!this.isCi) { + const initialAnswer = await this.askQuestion('Do you want to add lines to gitignore of your project? (y/N): '); + if (initialAnswer.trim().toLowerCase() !== 'y') { + return; + } + } + + console.log('Checking .gitignore and adding lines if necessary...'); + + const gitignorePath = path.join(this.pathToMagentoRootGitignore, '.gitignore'); + + // Read existing content if file exists + let existingLines = []; + if (fs.existsSync(gitignorePath)) { + const content = fs.readFileSync(gitignorePath, 'utf-8'); + existingLines = content.split(/\r?\n/); + } + + // Get vendor and theme + const { vendor, theme } = await this.setVendorAndTheme(__dirname); + + // Append missing lines + let updated = false; + for (let line of this.rulesToAddToIgnore) { + // Replace placeholders with actual values + line = line.replace('', vendor).replace('', theme); + + if (!existingLines.includes(line)) { + existingLines.push(line); + updated = true; + } + } + + // Write back if updated + if (updated) { + fs.writeFileSync(gitignorePath, existingLines.join('\n'), 'utf-8'); + console.log('.gitignore updated.'); + } else { + console.log('.gitignore already contains all required lines.'); + } + } + + async setVendorAndTheme() { + // Ask user for input if path structure is invalid + const vendor = await this.askQuestion('Enter the vendor name: '); + const theme = await this.askQuestion('Enter the theme name: '); + + return { vendor, theme }; + } +} + +new Install(); diff --git a/dev/tests/e2e/package-lock.json b/dev/tests/e2e/package-lock.json new file mode 100644 index 00000000000..0a1ba9b9f51 --- /dev/null +++ b/dev/tests/e2e/package-lock.json @@ -0,0 +1,129 @@ +{ + "name": "@elgentos/magento2-playwright", + "version": "3.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@elgentos/magento2-playwright", + "version": "3.0.1", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "@faker-js/faker": "^9.8.0", + "@playwright/test": "^1.57.0", + "@types/node": "^22.7.4", + "csv-parse": "^5.5.3", + "dotenv": "^16.4.5" + } + }, + "node_modules/@faker-js/faker": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.8.0.tgz", + "integrity": "sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.15.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz", + "integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/csv-parse": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", + "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + } + } +} diff --git a/dev/tests/e2e/package.json b/dev/tests/e2e/package.json new file mode 100644 index 00000000000..bc3e2c3646c --- /dev/null +++ b/dev/tests/e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "@elgentos/magento2-playwright", + "version": "3.0.1", + "author": "elgentos", + "license": "ISC", + "description": "A Playwright End-To-End (E2E) testing suite for Magento 2 with Hyva that helps you find (potential) issues on your webshop.", + "scripts": { + "postinstall": "node build.js; node install.js; npx playwright install", + "translate": "node translate-json.js nl_NL" + }, + "dependencies": { + "@faker-js/faker": "^9.8.0", + "@playwright/test": "^1.57.0", + "@types/node": "^22.7.4", + "csv-parse": "^5.5.3", + "dotenv": "^16.4.5" + } +} diff --git a/dev/tests/e2e/playwright-report/index.html b/dev/tests/e2e/playwright-report/index.html new file mode 100644 index 00000000000..d55a19b489d --- /dev/null +++ b/dev/tests/e2e/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/dev/tests/e2e/playwright.config.example.ts b/dev/tests/e2e/playwright.config.example.ts new file mode 100644 index 00000000000..ec48c518daa --- /dev/null +++ b/dev/tests/e2e/playwright.config.example.ts @@ -0,0 +1,151 @@ +// @ts-check + +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; +import fs from "node:fs"; + +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +function getTestFiles(baseDir: string, customDir?: string): string[] { + const baseFiles = new Set( + fs.readdirSync(baseDir) + .filter(file => file.endsWith('.spec.ts')) + .map(file => path.join(baseDir, file)) + ); + + if (!customDir || !fs.existsSync(customDir)) { + return Array.from(baseFiles); + } + + const customFiles = fs.readdirSync(customDir) + .filter(file => file.endsWith('.spec.ts')) + .map(file => path.join(customDir, file)); + + if (customFiles.length === 0) { + return Array.from(baseFiles); + } + + const testFiles = new Set(); + + // Get base files that have an override in custom + for (const file of baseFiles) { + const baseFilePath = path.join(baseDir, path.basename(file)); + const customFilePath = path.join(customDir, path.basename(file)); + + testFiles.add(fs.existsSync(customFilePath) ? customFilePath : baseFilePath); + } + + // Add custom tests that aren't in base + for (const file of customFiles) { + if (!baseFiles.has(path.basename(file))) { + testFiles.add(file); + } + } + + return Array.from(testFiles); +} + +const testFiles = getTestFiles( + path.join(__dirname, 'base-tests'), + path.join(__dirname, 'tests'), +); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: '.', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Increase default timeout */ + timeout: 150_000, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'https://hyva-demo.elgentos.io/', + + // Create a screenshot at the end of a test if the test fails. + // See https://playwright.dev/docs/api/class-testoptions#test-options-screenshot + screenshot: 'only-on-failure', + + // Collect trace when retrying a failed test. See https://playwright.dev/docs/trace-viewer + trace: 'retain-on-failure', + + /* Ignore https errors if they apply (should only happen on local) */ + ignoreHTTPSErrors: true, + }, + + /* + * Setup for global cookie to bypass CAPTCHA, remove '.example' when used. + * If this is disabled remove storageState from all project objects. + */ + globalSetup: require.resolve('./bypass-captcha.config.ts'), + + /* Configure projects for major browsers */ + projects: [ + // Import our auth.setup.ts file + //{ name: 'setup', testMatch: /.*\.setup\.ts/ }, + + { + name: 'chromium', + testMatch: testFiles, + use: { + ...devices['Desktop Chrome'], + storageState: './auth-storage/chromium-storage-state.json', + }, + }, + + { + name: 'firefox', + testMatch: testFiles, + use: { + ...devices['Desktop Firefox'], + storageState: './auth-storage/firefox-storage-state.json', + }, + }, + + { + name: 'webkit', + testMatch: testFiles, + use: { + ...devices['Desktop Safari'], + storageState: './auth-storage/webkit-storage-state.json', + }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/dev/tests/e2e/playwright.config.ts b/dev/tests/e2e/playwright.config.ts new file mode 100644 index 00000000000..2194c1edc4d --- /dev/null +++ b/dev/tests/e2e/playwright.config.ts @@ -0,0 +1,158 @@ +// @ts-check + +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; +import fs from "node:fs"; + +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +function getTestFiles(baseDir: string, customDir?: string): string[] { + const baseFiles = new Set( + fs.readdirSync(baseDir) + .filter(file => file.endsWith('.spec.ts')) + .map(file => path.join(baseDir, file)) + ); + + if (!customDir || !fs.existsSync(customDir)) { + return Array.from(baseFiles); + } + + const customFiles = fs.readdirSync(customDir) + .filter(file => file.endsWith('.spec.ts')) + .map(file => path.join(customDir, file)); + + if (customFiles.length === 0) { + return Array.from(baseFiles); + } + + const testFiles = new Set(); + + // Get base files that have an override in custom + for (const file of baseFiles) { + const baseFilePath = path.join(baseDir, path.basename(file)); + const customFilePath = path.join(customDir, path.basename(file)); + + testFiles.add(fs.existsSync(customFilePath) ? customFilePath : baseFilePath); + } + + // Add custom tests that aren't in base + for (const file of customFiles) { + if (!baseFiles.has(path.basename(file))) { + testFiles.add(file); + } + } + + return Array.from(testFiles); +} + +const testFiles = getTestFiles( + path.join(__dirname, 'base-tests'), + path.join(__dirname, 'tests'), +); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: '.', + /* Run tests in files in parallel */ + fullyParallel: true, + maxFailures: process.env.CI ? 2 : 0, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + // workers: process.env.CI ? 1 : undefined, + /* Increase default timeout */ + timeout: 150_000, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [['github'], ['list', { printSteps: true }], ['html', { open: 'never' }]] + : [['list', { printSteps: true }], ['html', { open: 'never' }]], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'https://hyva-demo.elgentos.io/', + + // Record video based on PLAYWRIGHT_VIDEO environment variable + // See https://playwright.dev/docs/api/class-testoptions#test-options-video + video: (process.env.PLAYWRIGHT_VIDEO as 'on' | 'off' | 'retain-on-failure' | 'on-first-retry') || 'retain-on-failure', + + // Create a screenshot at the end of a test if the test fails. + // See https://playwright.dev/docs/api/class-testoptions#test-options-screenshot + screenshot: (process.env.PLAYWRIGHT_SCREENSHOT as 'on' | 'off' | 'only-on-failure' | 'on-first-failure') || 'only-on-failure', + + // Collect trace when retrying a failed test. See https://playwright.dev/docs/trace-viewer + trace: 'retain-on-failure', + + /* Ignore https errors if they apply (should only happen on local) */ + ignoreHTTPSErrors: true, + }, + + /* + * Setup for global cookie to bypass CAPTCHA, remove '.example' when used. + * If this is disabled remove storageState from all project objects. + */ + globalSetup: require.resolve('./bypass-captcha.config.ts'), + + /* Configure projects for major browsers */ + projects: [ + // Import our auth.setup.ts file + //{ name: 'setup', testMatch: /.*\.setup\.ts/ }, + + { + name: 'chromium', + testMatch: testFiles, + use: { + ...devices['Desktop Chrome'], + userAgent: 'Playwright' + }, + }, + + { + name: 'firefox', + testMatch: testFiles, + use: { + ...devices['Desktop Firefox'], + userAgent: 'Playwright' + }, + }, + + { + name: 'webkit', + testMatch: testFiles, + use: { + ...devices['Desktop Safari'], + userAgent: 'Playwright' + }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/dev/tests/e2e/test-results/.last-run.json b/dev/tests/e2e/test-results/.last-run.json new file mode 100644 index 00000000000..cbcc1fbac11 --- /dev/null +++ b/dev/tests/e2e/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/dev/tests/e2e/tests/account.spec.ts b/dev/tests/e2e/tests/account.spec.ts new file mode 100644 index 00000000000..dceed12b967 --- /dev/null +++ b/dev/tests/e2e/tests/account.spec.ts @@ -0,0 +1,140 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { faker } from '@faker-js/faker'; +import { UIReference, outcomeMarker, slugs, inputValues} from '@config'; +import { requireEnv } from '@utils/env.utils'; + +import AccountPage from '@poms/frontend/account.page'; +import LoginPage from '@poms/frontend/login.page'; +import MainMenuPage from '@poms/frontend/mainmenu.page'; +import NewsletterSubscriptionPage from '@poms/frontend/newsletter.page'; +import RegisterPage from '@poms/frontend/register.page'; + +// Before each test, log in +test.beforeEach(async ({ page, browserName }) => { + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + const loginPage = new LoginPage(page); + await loginPage.login(emailInputValue, passwordInputValue); +}); + +test.describe('Account information actions', {annotation: {type: 'Account Dashboard', description: 'Test for Account Information'},}, () => { + + test.beforeEach(async ({page}) => { + await page.goto(slugs.account.accountOverviewSlug); + await page.waitForLoadState(); + + await expect( + page.locator('.block-dashboard-addresses .block-title', { + hasText: UIReference.address.addressBookTitle, + }) + ).toBeVisible(); + }); +}); + +test.describe.serial('Account address book actions', { annotation: {type: 'Account Dashboard', description: 'Tests for the Address Book'},}, () => { + + test.beforeEach(async ({page}) => { + await page.goto(slugs.account.addressIndexSlug, {waitUntil: "load"}); + // if page navigated to new address, no address had been added yet. + if(page.url().includes('new')){ + await expect(async () => { + await expect(page.getByText(UIReference.newAddress.addNewAddressTitle), + `Heading "${UIReference.newAddress.addNewAddressTitle}" is visible`).toBeVisible(); + }).toPass(); + } else { + await expect(async () => { + await expect(page.getByRole('heading', + { name: UIReference.address.addressBookTitle }), + `Heading "${UIReference.address.addressBookTitle}" is visible`).toBeVisible(); + }).toPass(); + } + }); + + /** + * @feature Add an address + * @given I am logged in + * @and I am on the account dashboard page + * @when I go to the page where I can add another address + * @when I fill in the required information + * @and I click the save button + * @then I should see a notification my address has been updated. + * @and The new address should be listed + */ + test('Add_an_address',{ tag: ['@address-actions', '@hot'] }, async ({page}) => { + await page.goto(slugs.account.addressNewSlug); + const accountPage = new AccountPage(page); + + const address = `${faker.location.streetAddress()} ${Math.floor(Math.random() * 100 + 1)}`; + const company = faker.company.name(); + + await accountPage.addNewAddress({ company: company, street: address}); + + await expect(page.getByText(address).first(), `Expect new address to be listed`).toBeVisible(); + //await expect(page.getByText(company).first(), `Expect new company name to be listed`).toBeVisible(); + }); + + /** + * @feature Magento 2 Update Address in Account + * @scenario User updates an existing address to their account + * @given I am logged in + * @and I am on the account dashboard page + * @when I go to the page where I can see my address(es) + * @when I click on the button to edit the address + * @and I fill in the required information correctly + * @then I click the save button + * @then I should see a notification my address has been updated. + * @and The updated address should be visible in the addres book page. + */ + test('Edit_existing_address',{ tag: ['@address-actions', '@hot'] }, async ({page}) => { + const accountPage = new AccountPage(page); + await page.goto(slugs.account.addressBookSlug); + let editAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.editAddressIconButton}).first(); + let isDefaultAddress = false; + + if(await editAddressButton.isHidden()){ + // The edit address button was not found, add another address first. + if(await page.getByRole('link', { name: 'Change Shipping Address' }).isVisible()) { + isDefaultAddress = true; + } else { + expect (page.url(), `Edit address button not found, check URL is to the new address page`).toBe(slugs.account.addressNewSlug); + await accountPage.addNewAddress(); + } + } + + // const companyName = faker.company.name(); + const address = `${faker.location.streetAddress()} ${Math.floor(Math.random() * 100 + 1)}`; + await accountPage.editExistingAddress({street:address}, isDefaultAddress); + + // await expect(page.getByText(companyName)).toBeVisible(); + await expect(page.getByText(address).first()).toBeVisible(); + }); + + /** + * @feature Magento 2 Delete Address from account + * @scenario User removes an address from their account + * @given I am logged in + * @and I am on the account dashboard page + * @when I go to the page where I can see my address(es) + * @when I click the trash button for the address I want to delete + * @and I click the confirmation button + * @then I should see a notification my address has been deleted. + * @and The address should be removed from the overview. + */ + test('Delete_an_address',{ tag: ['@address-actions', '@hot'] }, async ({page}) => { + const accountPage = new AccountPage(page); + await page.goto(slugs.account.addressBookSlug); + + let deleteAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.addressDeleteIconButton}).first(); + + if(await deleteAddressButton.isHidden()) { + // The delete address button was not found, add another address first. + await page.goto(slugs.account.addressNewSlug); + await accountPage.addNewAddress(); + } + await accountPage.deleteFirstAddressFromAddressBook(); + }); +}); diff --git a/dev/tests/e2e/tests/auth.setup.ts b/dev/tests/e2e/tests/auth.setup.ts new file mode 100644 index 00000000000..8a88818af62 --- /dev/null +++ b/dev/tests/e2e/tests/auth.setup.ts @@ -0,0 +1,31 @@ +// @ts-check + +import { test as setup, expect } from '@playwright/test'; +import path from 'path'; +import { UIReference, slugs } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +const authFile = path.join(__dirname, '../playwright/.auth/user.json'); + +setup('authenticate', async ({ page, browserName }) => { + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + // Perform authentication steps. Replace these actions with your own. + await page.goto(slugs.account.loginSlug); + await page.getByLabel(UIReference.credentials.emailFieldLabel, {exact: true}).fill(emailInputValue); + await page.getByLabel(UIReference.credentials.passwordFieldLabel, {exact: true}).fill(passwordInputValue); + await page.getByRole('button', { name: UIReference.credentials.loginButtonLabel }).click(); + // Wait until the page receives the cookies. + // + // Sometimes login flow sets cookies in the process of several redirects. + // Wait for the final URL to ensure that the cookies are actually set. + // await page.waitForURL(''); + // Alternatively, you can wait until the page reaches a state where all cookies are set. + await expect(page.getByRole('link', { name: UIReference.mainMenu.myAccountLogoutItem })).toBeVisible(); + + // End of authentication steps. + + await page.context().storageState({ path: authFile }); +}); diff --git a/dev/tests/e2e/tests/category.spec.ts b/dev/tests/e2e/tests/category.spec.ts new file mode 100644 index 00000000000..014ce482e24 --- /dev/null +++ b/dev/tests/e2e/tests/category.spec.ts @@ -0,0 +1,50 @@ +// @ts-check + +import { test } from '@playwright/test'; + +import CategoryPage from '@poms/frontend/category.page'; + +/** + * @feature Sort category page by price + * @scenario User sorts category page by price + * @given I navigate to the category page + * @when I open the 'Sort' dropdown + * @and I click the price button + * @then the URL should reflect this filter + * @and I should see products sorted by price + */ +test('Sort_category_by_price',{ tag: ['@category', '@cold']}, async ({page}) => { + const categoryPage = new CategoryPage(page); + await categoryPage.goToCategoryPage(); + + await categoryPage.sortProducts('price'); +}); + +/** + * @feature products per page + * @scenario User updates the amount of products shown on the page + * @given I navigate to the category page + * @when I change the 'Show' dropdown + * @then the URl should reflect this filter + * @and the amount of items should be the new amount I've selected + */ +/*test('Change_amount_of_products_shown',{ tag: ['@category', '@cold'],}, async ({page}) => { + const categoryPage = new CategoryPage(page); + await categoryPage.goToCategoryPage(); + + await categoryPage.showMoreProducts(); +});*/ + +/** + * @feature View switcher + * @scenario User switches from the grid to the list view + * @given I navigate to the category page + * @when I click the grid or list mode button + * @then the URl should reflect this updated view + * @and the reported selected view should not be the same as it was before I clicked the button + */ +/*test('Switch_from_grid_to_list_view',{ tag: ['@category', '@cold'],}, async ({page}) => { + const categoryPage = new CategoryPage(page); + await categoryPage.goToCategoryPage(); + await categoryPage.switchView(); +});*/ diff --git a/dev/tests/e2e/tests/checkout.spec.ts b/dev/tests/e2e/tests/checkout.spec.ts new file mode 100644 index 00000000000..e6482856114 --- /dev/null +++ b/dev/tests/e2e/tests/checkout.spec.ts @@ -0,0 +1,48 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { UIReference, slugs } from '@config'; +import { requireEnv } from '@utils/env.utils'; +import MagewireUtils from '@utils/magewire.utils'; + +import LoginPage from '@poms/frontend/login.page'; +import ProductPage from '@poms/frontend/product.page'; +import AccountPage from '@poms/frontend/account.page'; +import CheckoutPage from '@poms/frontend/checkout.page'; + +/** + * @feature BeforeEach runs before each test in this group. + * @scenario Add product to the cart, confirm it's there, then move to checkout. + * @given I am on any page + * @when I navigate to a (simple) product page + * @and I add it to my cart + * @then I should see a notification + * @when I navigate to the checkout + * @then the checkout page should be shown + * @and I should see the product in the minicart + */ +test.beforeEach(async ({ page }) => { + const magewire = new MagewireUtils(page); + magewire.startMonitoring(); + + const productPage = new ProductPage(page); + + await page.goto(slugs.productPage.simpleProductSlug); + await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + await page.goto(slugs.checkout.checkoutSlug); +}); + + +test.describe('Checkout (login required)', () => { + // Before each test, log in + test.beforeEach(async ({ page, browserName }) => { + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + const loginPage = new LoginPage(page); + await loginPage.login(emailInputValue, passwordInputValue); + await page.goto(slugs.checkout.checkoutSlug); + }); +}); + diff --git a/dev/tests/e2e/tests/config/element-identifiers.json b/dev/tests/e2e/tests/config/element-identifiers.json new file mode 100644 index 00000000000..92de6c80772 --- /dev/null +++ b/dev/tests/e2e/tests/config/element-identifiers.json @@ -0,0 +1,288 @@ +{ + "accountCreation": { + "createAccountButtonLabel": "Create an Account", + "createAccountTitleText" : "Create New Customer Account" + }, + "accountDashboard": { + "accountDashboardTitleLabel": "Account Information", + "accountSideBarLabel": "Sidebar Main", + "addAddressButtonLabel": "ADD NEW ADDRESS", + "addressBookArea": ".block-addresses-list", + "addressDeleteIconButton": "Delete", + "editAddressIconButton": "pencil-alt", + "accountInformationFieldLocator": ".column > div > div > .flex", + "links": { + "newsletterLink": "Newsletter Subscriptions" + } + }, + "address" : { + "addressBookTitle" : "Address Book" + }, + "adminGeneral": { + "tableSearchFieldLabel": "Search by keyword", + "tableFilterResetLabel": "Clear All", + "loadingSpinnerLocator": "#container .spinner", + "searchButtonLabel": "Search" + }, + "adminCustomers": { + "createNewCustomerButtonLabel": "Add New Customer", + "edit": { + "passwordFieldLabel": "Enter Password" + }, + "registration": { + "debtorNumberFieldLabel": "Debtor number", + "allowBulkPurchaseFieldLabel": "Allow bulk purchase", + "createAccountSaveButtonLabel": "Save Customer", + "createAccountSaveAndContinueButtonLabel": "Save and Continue Edit" + } + }, + "adminPage": { + "captchaIncorrectText" : "Incorrect CAPTCHA.", + "captchaDisabledLabel": "No", + "couponSearchFieldLocator" : "#promo_quote_grid_filter_coupon_code", + "dashboardHeadingText": "Dashboard", + "navigation": { + "customersButtonLabel": "Customers", + "marketingButtonLabel": "Marketing", + "storesButtonLabel": "Stores", + "salesButtonLabel": "Sales" + }, + "searchResultsText" : "records found", + "subNavigation": { + "allCustomersButtonLabel": "All Customers", + "cartPriceRulesButtonLabel": "Cart Price Rules", + "configurationButtonLabel": "Configuration", + "ordersButtonLabel": "Orders" + }, + "usernameFieldLabel": "Username", + "passwordFieldLabel": "Password", + "loginButtonLabel": "Sign In", + "usernameFieldId": "#username", + "passwordFieldId": "#login", + "loginButtonClass": ".action-login" + }, + "newAddress": { + "addNewAddressTitle": "Add New Address", + "cityNameLabel": "City", + "companyNameLabel": "Company", + "countryLabel": "Country", + "phoneNumberLabel": "Phone Number", + "provinceSelectLabel": "State/Province", + "provinceSelectFilterLabel": "Please select a region, state or province.", + "regionDropdownLocator": "#region_id", + "streetAddressLabel": "Street Address", + "saveAdressButton": "Save Address", + "zipCodeLabel": "Zip/Postal Code" + }, + "newsletterSubscriptions": { + "generalSubscriptionCheckLabel": "General Subscription", + "saveSubscriptionsButton": "Save" + }, + "productPage": { + "addToCartButtonLocator": "Add to Cart", + "addToCompareButtonLabel": "Add to Compare", + "addToWishlistButtonLabel": "Add to Wish List", + "simpleProductTitle": "Push It Messenger Bag", + "secondSimpleProducTitle": "Aim Analog Watch", + "simpleProductPrice": ".final-price .price-wrapper .price", + "configurableProductTitle": "Inez Full Zip Jacket", + "configurableProductSizeLabel": "Size", + "configurableProductColorLabel": "Color", + "configurableProductOptionForm": "#product_addtocart_form", + "configurableProductOptionValue": ".product-option-value-label", + "quantityFieldLabel": "Quantity", + "fullScreenOpenLabel": "Click to view image in", + "fullScreenCloseLabel": "Close fullscreen", + "thumbnailImageLabel": "View larger image", + "reviewCountLabel": "Show items per page" + }, + "categoryPage":{ + "activeViewLocator": ".active", + "categoryPageTitleText": "Top", + "firstFilterOptionLocator": "#filter-option-2-content", + "itemsOnPageAmountLocator": ".toolbar-number", + "itemsPerPageButtonLabel": "Show items per page", + "productGridLocator": ".products-grid", + "removeActiveFilterButtonLabel": "Remove This Item", + "sizeFilterButtonLabel": "Size", + "sizeLLinkLabel": "Filter Size L", + "sortByButtonLabel": "Sort by", + "sortByButtonLocator": ".form-select.sorter-options", + "viewSwitchLabel": "Products view mode", + "viewGridLabel": "Products view mode - Grid", + "viewListLabel": "Products view mode - List" + }, + "cart": { + "applyDiscountButtonLabel": "Apply Discount", + "cancelCouponButtonLabel": "Cancel Coupon", + "cartTitleText": "Shopping Cart", + "cartQuantityLabel": "Qty", + "discountInputFieldLabel": "Enter discount code", + "showDiscountFormButtonLabel": "Apply Discount Code", + "updateItemButtonLabel": "Update", + "updateShoppingCartButtonLabel": "Update Shopping Cart" + }, + "cartPriceRulesPage": { + "actionsSubtitleLabel": "Actions", + "activeStatusLabelLocator": ".admin__actions-switch-text", + "activeStatusSwitcherLocator": ".admin__actions-switch-label", + "addCartPriceRuleButtonLabel": "Add New Rule", + "clearSearchButtonLabel" : "Reset Filter", + "couponCodeFieldLabel": "Coupon Code", + "couponCodeActiveStatusText": "Active", + "couponTypeSelectField": "select[name='coupon_type']", + "customerGroupsSelectLabel": "Customer Groups", + "discountAmountFieldLabel": "Discount Amount", + "ruleNameFieldLabel": "Rule Name", + "saveRuleButtonLabel": "Save", + "websitesSelectLabel": "Websites" + }, + "checkout": { + "applyDiscountButtonLabel": "Apply Coupon", + "applyDiscountCodeLabel": "Apply Discount Code", + "cancelDiscountButtonLabel": "Cancel Coupon", + "cartDetailsLocator": "#checkout-cart-details div", + "continueShoppingLabel": "Continue Shopping", + "discountInputFieldLabel": "Enter discount code", + "openCartButtonLabel": "Cart", + "openCartButtonLabelCont": "item", + "openCartButtonLabelContMultiple": "items", + "openCartDetailsButtonLocator": "button[aria-controls=\"checkout-cart-details\"]", + "openDiscountFormLabel": "Apply Discount Code", + "paymentOptionCheckLabel": "Check / Money order", + "paymentOptionCreditCardLabel": "Credit Card", + "paymentOptionPaypalLabel": "PayPal", + "checkoutCartDetailsLocator": "#checkout-cart-details", + "creditCardNumberLabel": "Credit Card Number", + "creditCardExpiryLabel": "Expiration Date", + "creditCardCVVLabel": "Card Verification Number", + "creditCardNameLabel": "Name on Card", + "placeOrderButtonLabel": "Place Order", + "remove": "Remove", + "shippingAddressRadioLocator": "#shipping-details input[type='radio']", + "shippingMethodFixedLabel": "Fixed", + "shippingMethodTableRateLabel": "Table Rate", + "shippingPriceText": "Shipping & Handling (Flat Rate - Fixed)", + "taxPriceText": "Tax" + }, + "comparePage": { + "removeCompareLabel": "Remove Product", + "addToWishListLabel": "Add to Wish List", + "comparisonPageEmptyText": "You have no items to compare.", + "comparisonPageTitleText": "Compare Products" + }, + "configurationPage": { + "advancedAdministrationTabLabel": "Admin", + "advancedTabLabel": "Advanced", + "allowMultipleLoginsSelectField": "#admin_security_admin_account_sharing", + "allowMultipleLoginsSystemCheckbox": "#admin_security_admin_account_sharing_inherit", + "captchaSettingSelectField": "#customer_captcha_enable", + "captchaSettingSystemCheckbox": "#customer_captcha_enable_inherit", + "captchaSectionLabel": "CAPTCHA", + "customerConfigurationTabLabel": "Customer Configuration", + "customersTabLabel": "Customers", + "saveConfigButtonLabel": "Save Config", + "securitySectionLabel": "Security" + }, + "contactPage": { + "messageFieldSelector": "#comment" + }, + "credentials": { + "currentPasswordFieldLabel": "Current Password", + "emailFieldLabel": "Email", + "emailCheckoutFieldLabel": "Email address", + "loginButtonLabel": "Sign In", + "nameFieldLabel": "Name", + "newPasswordConfirmFieldLabel": "Confirm New Password", + "newPasswordFieldLabel": "New Password", + "passwordConfirmFieldLabel": "Confirm Password", + "passwordFieldLabel": "Password" + }, + "customerOverviewPage": { + "tableSearchFieldLabel": "Search by keyword" + }, + "financial" : { + "subTotal": "Subtotal", + "grandTotal": "Grand Total" + }, + "footerPage": { + "footerLocator": ".page-footer", + "currencyIdentifier": "#currency-heading", + "currencyLabel": "Currency", + "currencyDollar": "USD - US Dollar", + "currencyEuro": "EUR - Euro", + "newsletterInputElementLabel": "Email Address", + "newsletterSubscribeButtonLabel": "Subscribe", + "newsletterLabel": "Newsletter" + }, + "general": { + "addToCartLabel": "Add to Cart", + "closeMessageLabel": "Close message", + "errorMessageLocator": "#messages div .messages div .error span", + "errorMessageStreetAddressRequiredFieldText": "This is a required field.", + "genericPriceLabel": "Price", + "genericPriceSymbol": "$", + "genericSaveButtonLabel": "Save", + "genericSubmitButtonLabel": "Submit", + "headerLocator": "header", + "loadingSpinnerLocator": "#container .spinner", + "messageLocator": "#messages .messages div", + "removeLabel": "Remove", + "searchButtonLabel": "Search", + "successMessageLocator": "[ui-id=\"message-success\"]" + }, + "homePage": { + "homePageTitleText": "Hyvä Theme" + }, + "mainMenu": { + "addressBookButtonLabel" : "Address Book", + "createAccountButtonLabel" : "Create an Account", + "subCategoryItemText" : "Fitness Equipment", + "categoryItemText" : "Gear", + "loginButtonLabel" : "Sign In", + "miniCartLabel": "My Cart", + "myAccountButtonLabel": "My Account", + "myAccountLogoutItem": "Sign Out", + "myOrdersButtonLabel" : "My Orders", + "searchButtonLabel" : "Toggle search form", + "wishListButtonLabel" : "My Wish List" + }, + "miniCart": { + "cartDrawerLocator": "#minicart-content-wrapper", + "cartEmptyText": "You have no items in your shopping cart.", + "checkOutButtonLabel": "Checkout", + "editProductIconLabel": "Edit item", + "minicartButtonLocator": "#menu-cart-icon", + "minicartAmountBubbleLocator": "#menu-cart-icon > span", + "minicartPriceFieldClass": ".price-excluding-tax .minicart-price .price", + "miniCartToggleLabelEmpty": "Cart is empty", + "miniCartToggleLabelMultiItem": "items", + "miniCartToggleLabelOneItem": "1 item", + "miniCartToggleLabelPrefix": "Toggle minicart,", + "productQuantityFieldLabel": "Qty", + "removeProductIconLabel": "Remove item", + "toCartLinkLabel": "View and Edit Cart" + }, + "search": { + "searchBoxPlaceholderText" : "Search entire store here...", + "searchToggleLocator": "#menu-search-icon", + "searchInputLocator": "#search", + "suggestionBoxLocator": "#search_autocomplete", + "searchResultsTitle": "Search results for:", + "searchTermDropdownText" : "Search terms" + }, + "orderHistoryPage" : { + "orderHistoryTitle": "My Orders" + }, + "personalInformation": { + "changePasswordSwitchLabel": "Change Password", + "changeEmailCheckLabel": "Change Email", + "firstNameLabel": "First Name", + "lastNameLabel": "Last Name" + }, + "wishListPage": { + "wishListItemGridLabel": "#wishlist-view-form", + "wishListTitle" : "My Wish List", + "updateCompareListButtonLabel": "Update Wish List" + } +} diff --git a/dev/tests/e2e/tests/config/index.ts b/dev/tests/e2e/tests/config/index.ts new file mode 100644 index 00000000000..e76016fc555 --- /dev/null +++ b/dev/tests/e2e/tests/config/index.ts @@ -0,0 +1,39 @@ +// @ts-check + +import fs from 'fs'; +import path from 'path'; + +function deepMerge(target: any, source: any): any { + for (const key in source) { + if (source[key] instanceof Object && key in target) { + Object.assign(source[key], deepMerge(target[key], source[key])); + } + } + // Combine the two objects + return { ...target, ...source }; +} + +function loadAndMergeConfig(fileName: string) { + const fallbackPath = path.resolve(__dirname, fileName); + const currentPath = path.resolve(__dirname, '../../tests/config/', fileName); + + let currentConfig = {}; + let fallbackConfig = {}; + + if (fs.existsSync(currentPath)) { + currentConfig = JSON.parse(fs.readFileSync(currentPath, 'utf-8')); + } + + if (fs.existsSync(fallbackPath)) { + fallbackConfig = JSON.parse(fs.readFileSync(fallbackPath, 'utf-8')); + } + + // Use deepMerge instead of shallow merge + return deepMerge(fallbackConfig, currentConfig); +} + +export const UIReference = loadAndMergeConfig('element-identifiers.json'); +export const outcomeMarker = loadAndMergeConfig('outcome-markers.json'); +export const inputValues = loadAndMergeConfig('input-values.json'); +export const slugs = loadAndMergeConfig('slugs.json'); +export const toggles = loadAndMergeConfig('test-toggles.json'); \ No newline at end of file diff --git a/dev/tests/e2e/tests/config/input-values.json b/dev/tests/e2e/tests/config/input-values.json new file mode 100644 index 00000000000..1e8c94e39ac --- /dev/null +++ b/dev/tests/e2e/tests/config/input-values.json @@ -0,0 +1,64 @@ +{ + "accountCreation": { + "emailHandleValue":"test-user", + "emailHostValue": "gmail.com", + "firstNameValue": "John", + "lastNameValue": "Doe" + }, + "adminLogins": { + "allowMultipleLogins": "Yes" + }, + "captcha": { + "captchaDisabled": "No" + }, + "contact": { + "contactFormEmailValue": "robertbaratheon@gameofthrones.com", + "contactFormMessage": "Hello! I am filling out this form as a test only. Feel free to ignore this message." + }, + "coupon": { + "couponCodeRuleName": "Test coupon", + "couponType": "Specific Coupon" + }, + "addressCountries": [ + "Netherlands", + "United Kingdom", + "United States" + ], + "editedAddress": { + "editCityValue": "Pallet Town", + "editfirstNameValue": "Ash", + "editLastNameValue": "Ketchum", + "editStateValue": "Kansas", + "editStreetAddressValue": "House on the left", + "editZipCodeValue": "00151" + }, + "firstAddress": { + "firstCityValue": "Testing Valley", + "firstNonDefaultCountry": "Netherlands", + "firstPhoneNumberValue": "0622000000", + "firstProvinceValue": "Idaho", + "firstStreetAddressValue": "Testingstreet 1", + "firstZipCodeValue": "12345" + }, + "payment": { + "creditCard": { + "number": "4111111111111111", + "expiry": "12/25", + "cvv": "123", + "name": "Test User" + } + }, + "secondAddress": { + "secondCityValue": "Little Whinging", + "secondNonDefaultCountry": "United Kingdom", + "secondPhoneNumberValue": "0620081998", + "secondProvinceValue": "South Dakota", + "secondStreetAddressValue": "Under the Stairs", + "secondZipCodeValue": "67890" + }, + "search": { + "queryMultipleResults": "bag", + "querySpecificProduct": "Push It Messenger Bag", + "queryNoResults": "sdfasdfasddd" + } +} diff --git a/dev/tests/e2e/tests/config/outcome-markers.json b/dev/tests/e2e/tests/config/outcome-markers.json new file mode 100644 index 00000000000..bb6744d9cf1 --- /dev/null +++ b/dev/tests/e2e/tests/config/outcome-markers.json @@ -0,0 +1,86 @@ +{ + "account": { + "accountCreatedNotificationText": "Thank you for registering with Main Website Store.", + "accountPageTitle": "Account Information", + "addressBookTitle": "Customer Address", + "changedPasswordNotificationText": "You saved the account", + "createAccountHeaderText" : "Create New Customer Account", + "newsletterRemovedNotification": "We have removed your newsletter subscription.", + "newsletterSavedNotification": "We have saved your subscription.", + "newsletterSubscriptionTitle": "Newsletter Subscription", + "newsletterUpdatedNotification": "We have updated your subscription." + }, + "address": { + "addressDeletedNotification": "You deleted the address.", + "newAddressAddedNotifcation": "You saved the address." + }, + "adminGeneral": { + "searchResultsFoundText": "records found", + "activeFiltersText": "Active filters" + }, + "cart": { + "discountAppliedNotification": "You used coupon code", + "discountRemovedNotification": "You canceled the coupon code.", + "incorrectCouponCodeNotificationOne": "The coupon code", + "incorrectCouponCodeNotificationTwo": "is not valid.", + "priceReducedSymbols": "- $" + }, + "categoryPage" : { + "subCategoryPageTitle" : "Fitness Equipment" + }, + "checkout": { + "checkoutPriceReducedSymbol": "-$", + "couponAppliedNotification": "Your coupon was successfully applied", + "couponRemovedNotification": "Your coupon was successfully removed", + "incorrectDiscountNotification": "The coupon code isn't valid. Verify the code and try again.", + "orderPlacedNotification": "Thank you for your purchase!", + "orderPlacedNumberText": "Your order number is:" + }, + "comparePage": { + "productRemovedNotificationTextOne": "You removed product", + "productRemovedNotificationTextTwo": "from the comparison list.", + "productNotWishlistedNotificationText": "You must login or register to add items to your wishlist." + }, + "contactPage": { + "messageSentConfirmationText": "Thanks for contacting us with" + }, + "customerOverviewPage": { + "searchResultsFoundText": "records found" + }, + "footerPage": { + "newsletterSubscription": "Thank you for your subscription.", + "newsletterAlreadySubscribed": "This email address is already subscribed." + }, + "homePage": { + "firstProductName": "Aim Analog Watch" + }, + "logout": { + "logoutConfirmationText": "You have signed out" + }, + "magentoAdmin" : { + "configurationSavedText" : "You saved the configuration.", + "couponRuleSavedText" : "You saved the rule.", + "noResultsFoundText" : "We couldn't find any records." + }, + "miniCart": { + "configurableProductMinicartTitle": "Inez Full Zip Jacket", + "miniCartTitle": "My Cart", + "productQuantityChangedConfirmation": "was updated in your shopping cart", + "productRemovedConfirmation": "You removed the item.", + "simpleProductInCartTitle": "Push It Messenger Bag" + }, + "productPage": { + "borderClassRegex": ".* border-primary$", + "simpleProductAddedNotification": "You added" + }, + "login": { + "loginHeaderText" : "Customer Login", + "invalidCredentialsMessage": "The account sign-in was incorrect or your account is disabled temporarily. Please wait and try again later." + }, + "search": { + "noResultsMessage": "Your search returned no results." + }, + "wishListPage": { + "wishListAddedNotification": "has been added to your Wish List." + } +} diff --git a/dev/tests/e2e/tests/config/slugs.json b/dev/tests/e2e/tests/config/slugs.json new file mode 100644 index 00000000000..37676d0dcd1 --- /dev/null +++ b/dev/tests/e2e/tests/config/slugs.json @@ -0,0 +1,42 @@ +{ + "account": { + "accountOverviewSlug": "/customer/account/", + "addressBookSlug": "/customer/address", + "addressIndexSlug": "/customer/address/index", + "addressNewSlug": "customer/address/new", + "accountEditSlug": "/customer/account/edit/", + "changePasswordSlug": "/customer/account/edit/changepass/1/", + "createAccountSlug": "/customer/account/create/", + "loginSlug": "/customer/account/login/", + "orderHistorySlug": "/sales/order/history/" + }, + "cart": { + "cartProductChangeSlug": "/cart/configure/", + "cartSlug": "/checkout/cart/" + }, + "categoryPage": { + "categorySlug": "/men/tops-men.html", + "subcategorySlug" : "/men/tops-men.html?cat=14" + }, + "checkout": { + "checkoutSlug": "/checkout/", + "purchaseSuccessSlug": "/checkout/onepage/success/" + }, + "contact": { + "contactSlug": "/contact" + }, + "productPage": { + "configurableProductSlug": "/inez-full-zip-jacket.html", + "productComparisonSlug": "/catalog/product_compare/index/", + "secondSimpleProductSlug": "/aim-analog-watch.html", + "simpleProductSlug": "/push-it-messenger-bag.html", + "searchProductSlug": "catalogsearch/result/?q=Push+It+Messenger+Bag" + }, + "search": { + "resultsSlug": "/catalogsearch/result/" + }, + "wishList": { + "wishListSlug": "wishlist/", + "wishListRegex": ".*wishlist.*" + } +} diff --git a/dev/tests/e2e/tests/config/test-toggles.json b/dev/tests/e2e/tests/config/test-toggles.json new file mode 100644 index 00000000000..967305142bf --- /dev/null +++ b/dev/tests/e2e/tests/config/test-toggles.json @@ -0,0 +1,5 @@ +{ + "general": { + "setup": false + } +} diff --git a/dev/tests/e2e/tests/footer.spec.ts b/dev/tests/e2e/tests/footer.spec.ts new file mode 100644 index 00000000000..d29f2103d75 --- /dev/null +++ b/dev/tests/e2e/tests/footer.spec.ts @@ -0,0 +1,39 @@ +// @ts-check + +import { test } from '@playwright/test'; +import { outcomeMarker } from '@config'; +import NotificationValidatorUtils from "@utils/notificationValidator.utils"; + +import NewsletterPage from "@poms/frontend/newsletter.page"; +import Footer from '@poms/frontend/footer.page'; + +test.describe('Footer', () => { + + test( + 'Footer_is_available', + {tag: ['@footer', '@cold']}, + async ({page}) => { + const footer = new Footer(page); + + await page.goto(''); + await footer.goToFooterElement(); + } + ) + + test( + 'Footer_newsletter_subscription', + {tag: ['@footer', '@cold']}, + async ({page}, testInfo) => { + const newsletterPage = new NewsletterPage(page); + + await page.goto(''); + await newsletterPage.footerSubscribeToNewsletter(); + + const subscriptionOutput = outcomeMarker.footerPage.newsletterSubscription; + const notificationType = 'Newsletter subscription notification'; + + const notificationValidator = new NotificationValidatorUtils(page, testInfo); + await notificationValidator.validate(notificationType, subscriptionOutput); + } + ) +}) diff --git a/dev/tests/e2e/tests/healthcheck.spec.ts b/dev/tests/e2e/tests/healthcheck.spec.ts new file mode 100644 index 00000000000..6d39ff3d563 --- /dev/null +++ b/dev/tests/e2e/tests/healthcheck.spec.ts @@ -0,0 +1,64 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { UIReference, slugs } from '@config'; + +test.describe('Page health checks', () => { + test('Homepage_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { + const homepageURL = process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL; + if (!homepageURL) { + throw new Error("PLAYWRIGHT_BASE_URL has not been defined in the .env file."); + } + + const homepageResponsePromise = page.waitForResponse(homepageURL); + await page.goto(homepageURL); + const homepageResponse = await homepageResponsePromise; + expect(homepageResponse.status(), 'Homepage should return 200').toBe(200); + + await expect( + page.getByRole('heading', {name: UIReference.homePage.homePageTitleText, level: 1}), + 'Homepage has a visible title' + ).toBeVisible(); + }); + + test('Plp_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { + const plpResponsePromise = page.waitForResponse(slugs.categoryPage.categorySlug); + await page.goto(slugs.categoryPage.categorySlug); + const plpResponse = await plpResponsePromise; + expect(plpResponse.status(), 'PLP should return 200').toBe(200); + + await expect( + page.getByRole('heading', {name: UIReference.categoryPage.categoryPageTitleText}), + 'PLP has a visible title' + ).toBeVisible(); + }); + + test('Pdp_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { + const pdpResponsePromise = page.waitForResponse(slugs.productPage.simpleProductSlug); + await page.goto(slugs.productPage.simpleProductSlug); + const pdpResponse = await pdpResponsePromise; + expect(pdpResponse.status(), 'PDP should return 200').toBe(200); + + await expect( + page.getByRole('heading', {level: 1, name: UIReference.productPage.simpleProductTitle}), + 'PDP has a visible title' + ).toBeVisible(); + }); + + test('Checkout_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { + const responsePromise = page.waitForResponse(slugs.checkout.checkoutSlug); + + await page.goto(slugs.checkout.checkoutSlug); + const response = await responsePromise; + + expect(response.status(), 'Cart empty, checkout should return 302').toBe(302); + expect(page.url(), 'Cart empty, checkout should redirect to cart').toContain(slugs.cart.cartSlug); + + await expect( + page.getByRole('heading', {name: UIReference.cart.cartTitleText}), + 'Cart has a visible title' + ).toBeVisible(); + + expect((await page.request.head(page.url())).status(), `Current page (${page.url()}) should return 200`).toBe(200); + }); +}); diff --git a/dev/tests/e2e/tests/home.spec.ts b/dev/tests/e2e/tests/home.spec.ts new file mode 100644 index 00000000000..f7bbb80af98 --- /dev/null +++ b/dev/tests/e2e/tests/home.spec.ts @@ -0,0 +1,17 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { outcomeMarker } from '@config'; + +import MainMenuPage from '@poms/frontend/mainmenu.page'; +import HomePage from '@poms/frontend/home.page'; + +test('Add_product_on_homepage_to_cart',{ tag: ['@homepage', '@cold']}, async ({page}) => { + const homepage = new HomePage(page); + const mainmenu = new MainMenuPage(page); + + await page.goto(''); + await homepage.addHomepageProductToCart(); + await mainmenu.openMiniCart(); + await expect(page.getByText('x ' + outcomeMarker.homePage.firstProductName), 'product should be visible in cart').toBeVisible(); +}); diff --git a/dev/tests/e2e/tests/login.spec.ts b/dev/tests/e2e/tests/login.spec.ts new file mode 100644 index 00000000000..29ac85772d7 --- /dev/null +++ b/dev/tests/e2e/tests/login.spec.ts @@ -0,0 +1,47 @@ +// @ts-check + +import { test as base, expect } from '@playwright/test'; +import { outcomeMarker, inputValues } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +import LoginPage from '@poms/frontend/login.page'; + +base('User_logs_in_with_valid_credentials', {tag: '@hot'}, async ({page, browserName}) => { + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + // We can't move this browser specific check inside LoginPage because the + // variable name differs per browser engine. + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + const loginPage = new LoginPage(page) + await loginPage.login(emailInputValue, passwordInputValue); + await page.waitForLoadState('networkidle'); + + // Check customer section data in localStorage and verify name + const customerData = await page.evaluate(() => { + const data = localStorage.getItem('mage-cache-storage'); + return data ? data : null; + }); + + expect(customerData, 'Customer data should exist in localStorage').toBeTruthy(); + expect(customerData, 'Customer data should contain customer information').toContain('customer'); + + // Parse the JSON and verify firstname and lastname + const parsedData = await page.evaluate(() => { + const data = localStorage.getItem('mage-cache-storage'); + return data ? JSON.parse(data) : null; + }); + + expect(parsedData.customer.firstname, 'Customer firstname should match').toBe(inputValues.accountCreation.firstNameValue); + expect(parsedData.customer.fullname, 'Customer lastname should match').toContain(inputValues.accountCreation.lastNameValue); +}); + +base('Invalid_credentials_are_rejected', async ({page}) => { + const loginPage = new LoginPage(page); + await loginPage.loginExpectError('invalid@example.com', 'wrongpassword', outcomeMarker.login.invalidCredentialsMessage); +}); + +base('Login_fails_with_missing_password', async ({page}) => { + const loginPage = new LoginPage(page); + await loginPage.loginExpectError('invalid@example.com', '', ''); +}); diff --git a/dev/tests/e2e/tests/mainmenu.spec.ts b/dev/tests/e2e/tests/mainmenu.spec.ts new file mode 100644 index 00000000000..996567ff9a1 --- /dev/null +++ b/dev/tests/e2e/tests/mainmenu.spec.ts @@ -0,0 +1,100 @@ +// @ts-check + +import { test } from '@playwright/test'; +import {UIReference, slugs, inputValues} from '@config'; + +import LoginPage from '@poms/frontend/login.page'; +import MainMenuPage from '@poms/frontend/mainmenu.page'; +import ProductPage from '@poms/frontend/product.page'; +import { requireEnv } from '@utils/env.utils'; + +// no resetting storageState, mainmenu has more functionalities when logged in. + +test.describe('User tests (logged in)', () => { + // Before each test, log in + test.beforeEach(async ({ page, browserName }) => { + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + const loginPage = new LoginPage(page); + await loginPage.login(emailInputValue, passwordInputValue); + }); + + /** + * @feature Logout + * @scenario The user can log out + * @given I am logged in + * @and I am on any Magento 2 page + * @when I open the account menu + * @and I click the Logout option + * @then I should see a message confirming I am logged out + */ + test('User_logs_out', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.logout(); + }); + + /** + * @feature Navigate to account page + * @scenario user navigates to account page + * @given I am logged in + * @and I am on any magento 2 page + * @when I open the account menu + * @and I click the account button + * @and I click the 'my account' button + * @then I should be navigated to my account + */ + test('Navigate_to_account_page', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.gotoMyAccount(); + }); + + /** + * @feature Navigate to wishlist + * @scenario user navigates to their wishlist + * @given I am logged in + * @and I am on any magento 2 page + * @when I open the account menu + * @and I click on the wishlist button + * @then I should be navigated to the wishlist page + */ + test('Navigate_to_wishlist', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.goToWishList(); + }); + + /** + * @feature Navigate to orders overview + * @scenario user navigates to their order history + * @given I am logged in + * @and I am on any magento 2 page + * @when I open the account menu + * @and I click on the 'My orders' button + * @then I should be navigated to the page with my order history + */ + test('Navigate_to_orders', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.goToOrders(); + }); + + /** + * @feature Navigate to address book + * @scenario user navigates to their address book + * @given I am logged in + * @and I am on any Magento 2 page + * @when I open the account menu + * @and I click on the 'Address book' button + * @then I should be navigated to the page with my order history + * @and I should see an appropriate title based on whether an address has been added + */ + test('Navigate_to_address_book', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { + const mainMenu = new MainMenuPage(page); + await mainMenu.goToAddressBook(); + }); +}); + + + + + diff --git a/dev/tests/e2e/tests/minicart.spec.ts b/dev/tests/e2e/tests/minicart.spec.ts new file mode 100644 index 00000000000..78a11fd67db --- /dev/null +++ b/dev/tests/e2e/tests/minicart.spec.ts @@ -0,0 +1,79 @@ +// @ts-check + +import {test, expect} from '@playwright/test'; +import {UIReference, outcomeMarker, slugs} from '@config'; + +import MainMenuPage from '@poms/frontend/mainmenu.page'; +import ProductPage from '@poms/frontend/product.page'; +import MiniCartPage from '@poms/frontend/minicart.page'; + +test.describe('Minicart Actions', {annotation: {type: 'Minicart', description: 'Minicart simple product tests'},}, () => { + + /** + * @feature BeforeEach runs before each test in this group. + * @scenario Add a product to the cart and confirm it's there. + * @given I am on any page + * @when I navigate to a (simple) product page + * @and I add it to my cart + * @then I should see a notification + * @when I click the cart in the main menu + * @then the minicart should become visible + * @and I should see the product in the minicart + */ + test.beforeEach(async ({ page }) => { + const mainMenu = new MainMenuPage(page); + const productPage = new ProductPage(page); + + await page.goto(slugs.productPage.simpleProductSlug); + await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + await mainMenu.openMiniCart(); + await expect(page.getByText(outcomeMarker.miniCart.simpleProductInCartTitle).first()).toBeVisible(); + }); + + /** + * @feature Magento 2 Minicart to Cart + * @scenario User adds a product to cart, then uses minicart to navigate to their cart + * @given I have added a (simple) product to the cart and opened the minicart + * @when I click on the 'to cart' link + * @then I should be navigated to the cart page + */ + + test('Add_product_to_minicart_and_go_to_cart',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { + const miniCart = new MiniCartPage(page); + await miniCart.goToCart(); + }); + + /** + * @feature Price Check: Simple Product on Product Detail Page (PDP) and Minicart + * @scenario The price on a PDP should be the same as the price in the minicart + * @given I have added a (simple) product to the cart and opened the minicart + * @then the price listed in the minicart (per product) should be the same as the price on the PDP + */ + test('Pdp_price_matches_minicart_price',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { + const miniCart = new MiniCartPage(page); + await miniCart.checkPriceWithProductPage(); + }); +}); + +test.describe('Minicart Actions', {annotation: {type: 'Minicart', description: 'Minicart configurable product tests'},}, () => { + /** + * @feature BeforeEach runs before each test in this group. + * @scenario Add a configurable product to the cart and confirm it's there. + * @given I am on any page + * @when I navigate to a (simple) product page + * @and I add it to my cart + * @then I should see a notification + * @when I click the cart in the main menu + * @then the minicart should become visible + * @and I should see the product in the minicart + */ + test.beforeEach(async ({ page }) => { + const mainMenu = new MainMenuPage(page); + const productPage = new ProductPage(page); + + await page.goto(slugs.productPage.configurableProductSlug); + await productPage.addConfigurableProductToCart(UIReference.productPage.configurableProductTitle, slugs.productPage.configurableProductSlug, '2'); + await mainMenu.openMiniCart(); + await expect(page.getByText(outcomeMarker.miniCart.configurableProductMinicartTitle)).toBeVisible(); + }); +}); diff --git a/dev/tests/e2e/tests/poms/adminhtml/customers.page.ts b/dev/tests/e2e/tests/poms/adminhtml/customers.page.ts new file mode 100644 index 00000000000..295e7fee36b --- /dev/null +++ b/dev/tests/e2e/tests/poms/adminhtml/customers.page.ts @@ -0,0 +1,188 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, inputValues, outcomeMarker } from '@config'; +import { requireEnv } from "@utils/env.utils"; + +class AdminCustomers { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * @feature Customer Management + * @scenario Check if a customer exists by email address + * @given the admin is on the Magento dashboard + * @when the admin navigates to Customers > All Customers + * @and the customer table is fully loaded + * @and the admin searches for a specific email address + * @then reset the table filter + * @then the system returns whether a customer with that email exists in the customer list + */ + async checkIfCustomerExists(email: string){ + const mainMenuCustomersButton = this.page.getByRole('link', {name: UIReference.adminPage.navigation.customersButtonLabel}).first(); + const allCustomersLink = this.page.getByRole('link', {name: UIReference.adminPage.subNavigation.allCustomersButtonLabel}); + const customersSearchField = this.page.getByRole('textbox', {name: UIReference.customerOverviewPage.tableSearchFieldLabel}); + + // loop clicking the 'Customers' button until clicking it show the subnavigation + await expect(async() =>{ + await mainMenuCustomersButton.press('Enter'); + await expect(allCustomersLink, `Link to "All Customers" is visible`).toBeVisible({timeout: 5000}); + }).toPass(); + + await allCustomersLink.click(); + + // Wait for URL. If loading symbol is visible, wait for it to go away + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/**`); + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + await customersSearchField.waitFor(); + await customersSearchField.fill(email); + await this.page.getByRole('button', {name: UIReference.general.searchButtonLabel}).click(); + + // Wait for the loader spinner to be hidden + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Loop to ensure the 'results found' text is visible + // await expect( + // this.page.getByText(outcomeMarker.adminGeneral.activeFiltersText).first(), + // "There are active filters." + // ).toBeVisible(); + + // Return true (email found) or false (email not found) + const emailIsFound = await this.page.getByRole('cell', {name:email}).locator('div').isVisible(); + + // Click 'Clear all' button on filtered table to reset the table state. + await this.page.getByRole('button', {name: UIReference.adminGeneral.tableFilterResetLabel}).click(); + + // Wait for the loader spinner to be hidden + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + await expect( + this.page.getByText(outcomeMarker.adminGeneral.activeFiltersText).first(), + "There are no filters active." + ).toBeHidden(); + + return emailIsFound; + } + + /** + * @feature Customer Management + * @scenario Create a new customer account + * @given the admin is on the Magento dashboard + * @when the admin navigates to Customers > All Customers + * @and clicks the 'Create New Customer' button + * @then the admin fills in the mandatory fields and optional fields for a new customer account + * @and the system saves the customer account and navigates to the account edit page + * @and displays a confirmation message that the customer was saved + */ + async createNewCustomerAccount( + firstName: string, + lastName: string, + email: string + ) { + const createNewCustomersLink = this.page.getByRole('button', {name: UIReference.adminCustomers.createNewCustomerButtonLabel}); + await createNewCustomersLink.click(); + + // Wait for URL. If loading symbol is visible, wait for it to go away + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/new/**`); + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + const accountCreationFirstNameField = this.page.getByLabel(UIReference.personalInformation.firstNameLabel); + const accountCreationLastNameField = this.page.getByLabel(UIReference.personalInformation.lastNameLabel); + const accountCreationEmailField = this.page.getByLabel(UIReference.credentials.emailFieldLabel, { exact: true}); + const accountCreationConfirmButton = this.page.getByRole('button', {name: UIReference.adminCustomers.registration.createAccountSaveAndContinueButtonLabel}); + const customersSearchField = this.page.getByRole('textbox', {name: UIReference.adminGeneral.tableSearchFieldLabel}); + + // Optional fields: + const allowBulkPurchaseSwitcher = this.page.locator(UIReference.cartPriceRulesPage.activeStatusSwitcherLocator).first(); + + await accountCreationFirstNameField.fill(firstName); + await accountCreationLastNameField.fill(lastName); + await accountCreationEmailField.fill(email); + await allowBulkPurchaseSwitcher.click(); + await accountCreationConfirmButton.click(); + + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/edit/**`); + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + + await expect( + this.page.locator(UIReference.general.messageLocator).filter({hasText: 'You saved the customer.'}) + ).toBeVisible(); + } + + await this.approveAccount(email); + } + + /** + * @feature Customer Management + * @scenario Approve a customer account + * @given the admin is on the Magento dashboard + * @when the admin navigates to Customers > All Customers + * @and searches for a specific email address + * @then the admin clicks on the 'Edit' link for the corresponding customer + * @and approves the customer account + * @and the system displays a confirmation message that the customer account has been approved + */ + async approveAccount(email: string) { + + const customersSearchField = this.page.getByRole('textbox', {name: UIReference.adminGeneral.tableSearchFieldLabel}); + const editAccountButton = this.page.getByRole('link', {name: 'Edit'}).first() + const approvalButtonAccountEdit = this.page.getByRole('button', {name: 'Approve'}) + + await customersSearchField.waitFor(); + await customersSearchField.fill(email); + await this.page.getByRole('button', {name: UIReference.adminGeneral.searchButtonLabel}).click(); + + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Loop to ensure the 'results found' text is visible + await expect(async() =>{ + await this.page.getByText(outcomeMarker.customerOverviewPage.searchResultsFoundText).first(); + }).toPass(); + + // Return true (email found) or false (email not found) + await this.page.getByRole('cell', {name:email}).locator('div').isVisible(); + + await expect(async() => { + editAccountButton.click(); + }).toPass(); + + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/edit/**`); + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + console.log('Spinner is visible'); + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Press approval button when approval button is visible + if (await approvalButtonAccountEdit.isVisible()) { + await approvalButtonAccountEdit.click(); + + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/customer/index/edit/**`); + if (await this.page.locator(UIReference.general.loadingSpinnerLocator).isVisible()) { + console.log('Spinner is visible'); + await this.page.locator(UIReference.general.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + await expect( + this.page + .locator(UIReference.general.messageLocator) + .filter({ hasText: 'Customer account has been approved!' }) + ).toBeVisible(); + } + } +} + +export default AdminCustomers; diff --git a/dev/tests/e2e/tests/poms/adminhtml/login.page.ts b/dev/tests/e2e/tests/poms/adminhtml/login.page.ts new file mode 100644 index 00000000000..cc95914e762 --- /dev/null +++ b/dev/tests/e2e/tests/poms/adminhtml/login.page.ts @@ -0,0 +1,184 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, inputValues, outcomeMarker } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +class AdminLogin { + readonly page: Page; + readonly adminLoginEmailField: Locator; + readonly adminLoginPasswordField: Locator; + readonly adminLoginButton: Locator; + + constructor(page: Page) { + this.page = page; + this.adminLoginEmailField = page.locator(UIReference.adminPage.usernameFieldId); + this.adminLoginPasswordField = page.locator(UIReference.adminPage.passwordFieldId); + this.adminLoginButton = page.locator(UIReference.adminPage.loginButtonClass); + } + + /** + * @feature Magento Admin Configuration + * @scenario Disable the login CAPTCHA on the admin panel + * @given the admin is logged into the Magento dashboard + * @when the admin navigates to Stores > Configuration > Customers > Customer Configuration > CAPTCHA section + * @and the "Use system value" checkbox for CAPTCHA is unchecked + * @and the "Enable CAPTCHA on Admin Login" select field is visible + * @and the current setting is "Yes" + * @then the admin changes the setting to "No" + * @and clicks the Save Config button + * @then the system displays a success message confirming the configuration was saved + */ + async disableLoginCaptcha() { + const mainMenuStoresButton = this.page.getByRole('link', { name: UIReference.adminPage.navigation.storesButtonLabel}); + // selecting first specifically because plugins can place another 'configuration' link in this menu. + const storeSettingsConfigurationLink = this.page.getByRole('link', { name: UIReference.adminPage.subNavigation.configurationButtonLabel }).first(); + + await expect(async () => { + await mainMenuStoresButton.click(); + await expect(storeSettingsConfigurationLink, `Link to Store Configuration is visible`).toBeVisible(); + }).toPass(); + + await storeSettingsConfigurationLink.click(); + + const customersTab = this.page.getByRole('tab', { name: UIReference.configurationPage.customersTabLabel }); + const customerConfigurationLink = this.page.getByRole('link', { name: UIReference.configurationPage.customerConfigurationTabLabel }); + await customersTab.click(); + await customerConfigurationLink.waitFor(); + await customerConfigurationLink.click(); + + const captchaSettingsBlock = this.page.getByRole('link', { name: UIReference.configurationPage.captchaSectionLabel }) + .filter({hasNotText: 'documentation'}); + const captchaSettingsSystemValueCheckbox = this.page.locator(UIReference.configurationPage.captchaSettingSystemCheckbox); + + await captchaSettingsBlock.waitFor(); + + if(!await captchaSettingsSystemValueCheckbox.isVisible()) { + await captchaSettingsBlock.click(); + await expect(captchaSettingsSystemValueCheckbox, `Checkbox "Use system value" for CAPTCHA is visible`).toBeVisible(); + } + + if(await captchaSettingsSystemValueCheckbox.isChecked()){ + await captchaSettingsSystemValueCheckbox.uncheck(); + } + + const captchaSettingSelectField = this.page.locator(UIReference.configurationPage.captchaSettingSelectField); + const selectedOption = await captchaSettingSelectField.locator('option:checked').textContent(); + + // We only have to perform these steps if the option is set to 'Yes' + if(selectedOption == 'Yes') { + await captchaSettingSelectField.selectOption({label: inputValues.captcha.captchaDisabled}); + + const saveConfigButton = this.page.getByRole('button', { name: UIReference.configurationPage.saveConfigButtonLabel }); + await saveConfigButton.click(); + + await expect(this.page.locator(UIReference.general.messageLocator).filter( + {hasText: outcomeMarker.magentoAdmin.configurationSavedText}), + `Notification "${outcomeMarker.magentoAdmin.configurationSavedText}" is visible`).toBeVisible(); + } else { + await expect(selectedOption,`CAPTCHA is disabled`) + .toEqual(expect.stringContaining(UIReference.adminPage.captchaDisabledLabel)); + } + } + + /** + * @feature Enable multiple admin logins in Magento + * @scenario Admin enables the ability for multiple users to log in with the same admin account + * @given the user is on the Magento admin dashboard + * @when the user navigates to Stores > Configuration > Advanced > Admin > Security + * @and the "Allow Multiple Admin Account Login" field is visible + * @and the "Use system value" checkbox is unchecked + * @and the select field value is "No" + * @then the user selects "Yes" from the dropdown + * @and clicks the Save Config button + * @then the system displays a success message + */ + async enableMultipleAdminLogins() { + const mainMenuStoresButton = this.page.getByRole('link', { name: UIReference.adminPage.navigation.storesButtonLabel}); + // selecting first specifically because plugins can place another 'configuration' link in this menu. + const storeSettingsConfigurationLink = this.page.getByRole('link', { name: UIReference.adminPage.subNavigation.configurationButtonLabel }).first(); + + await expect(async () => { + await mainMenuStoresButton.click(); + await expect(storeSettingsConfigurationLink, `Link to Store Configuration is visible`).toBeVisible(); + }).toPass(); + + await storeSettingsConfigurationLink.click(); + + const advancedConfigurationTab = this.page.getByRole('tab', { name: UIReference.configurationPage.advancedTabLabel }); + const advancedConfigAdminLabel = this.page.getByRole('link', { name: UIReference.configurationPage.advancedAdministrationTabLabel, exact: true }); + await advancedConfigurationTab.click(); + await advancedConfigAdminLabel.waitFor(); + await advancedConfigAdminLabel.click(); + + const advancedConfigSecuritySection = this.page.getByRole('link', { name: UIReference.configurationPage.securitySectionLabel }); + const multipleLoginsSystemCheckbox = this.page.locator(UIReference.configurationPage.allowMultipleLoginsSystemCheckbox); + + await advancedConfigSecuritySection.waitFor(); + if (!await multipleLoginsSystemCheckbox.isVisible()) { + await advancedConfigSecuritySection.click(); + } + + await expect(multipleLoginsSystemCheckbox, `Checkbox for multiple admin logins is visible`).toBeVisible(); + + // make sure the 'use system value' option is not checked + const adminAccountSharingSystemValueCheckbox = this.page.locator(UIReference.configurationPage.allowMultipleLoginsSystemCheckbox); + if (await adminAccountSharingSystemValueCheckbox.isChecked()) { + await adminAccountSharingSystemValueCheckbox.uncheck(); + } + + const allowMultipleLoginSelectField = this.page.locator(UIReference.configurationPage.allowMultipleLoginsSelectField); + const selectedOption = await allowMultipleLoginSelectField.locator('option:checked').textContent(); + + // We only have to perform these steps if the option is set to 'No' + if(selectedOption == 'No') { + await allowMultipleLoginSelectField.selectOption({label: inputValues.adminLogins.allowMultipleLogins}); + + const saveConfigButton = this.page.getByRole('button', { name: UIReference.configurationPage.saveConfigButtonLabel }); + await saveConfigButton.click(); + + await expect(this.page.locator(UIReference.general.messageLocator).filter( + {hasText: outcomeMarker.magentoAdmin.configurationSavedText}), + `Notification "${outcomeMarker.magentoAdmin.configurationSavedText}" is visible`).toBeVisible(); + } + } + + /** + * @feature Login to Magento admin dashboard + * @scenario User logs in to admin dashboard + * @given the admin slug environment variable is defined + * @and the user navigates to the admin login page + * @when the user enters a valid username and password + * @and the user clicks the login button + * @then the user should see the dashboard heading displayed + */ + async login(username: string, password: string){ + await this.page.goto(requireEnv('MAGENTO_ADMIN_SLUG')); + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}`); + + if(await this.page.getByRole('heading', {name: UIReference.adminPage.dashboardHeadingText}).isVisible()) { + // already logged in + return; + } + + await this.adminLoginEmailField.fill(username); + await this.adminLoginPasswordField.fill(password); + await this.adminLoginButton.click(); + + const captchaNotification = this.page.locator(UIReference.general.messageLocator).filter({hasText: UIReference.adminPage.captchaIncorrectText}); + + if(await captchaNotification.isVisible()) { + console.log('CAPTCHA field is visible, automated login not possible!'); + throw new Error("CAPTCHA field is visible, automated login not possible!"); + } + + const dashboardLabel = this.page.getByRole('heading',{level:1, name: UIReference.adminPage.dashboardHeadingText}); + + // expect the H1 'Dashboard' to be visible + await expect(async () => { + await expect(dashboardLabel, `Title "Dashboard" is visible`).toBeVisible(); + }).toPass(); + } +} + +export default AdminLogin; diff --git a/dev/tests/e2e/tests/poms/adminhtml/marketing.page.ts b/dev/tests/e2e/tests/poms/adminhtml/marketing.page.ts new file mode 100644 index 00000000000..452a38abfc7 --- /dev/null +++ b/dev/tests/e2e/tests/poms/adminhtml/marketing.page.ts @@ -0,0 +1,128 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import {UIReference, inputValues, outcomeMarker} from '@config'; + +class AdminMarketing { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * @feature Cart Price Rules Configuration + * @scenario Add or activate a cart price rule with a specific coupon code + * @given the admin is on the Magento dashboard + * @when the coupon exists but is inactive + * @then the admin activates the existing coupon and saves the rule + * @but if the coupon does not exist + * @then the admin creates a new cart price rule with the given coupon code + * @and selects all websites and customer groups + * @and sets the coupon type and discount amount + * @and clicks the Save button + * @then the system displays a success message confirming the rule was saved + */ + async addCartPriceRule(magentoCouponCode: string){ + let resultMessage = ""; + + // Force specific viewport size to deal with webkit issues + await this.page.setViewportSize({ + width: 1920, + height: 1080 + }) + + const mainMenuMarketingButton = this.page.getByRole('link', {name: UIReference.adminPage.navigation.marketingButtonLabel}); + const cartPriceRulesLink = this.page.getByRole('link', {name: UIReference.adminPage.subNavigation.cartPriceRulesButtonLabel}); + await expect(mainMenuMarketingButton, `Button for Marketing is visible`).toBeVisible(); + + await expect(async () => { + await mainMenuMarketingButton.click(); + await expect(cartPriceRulesLink, `Button for Cart Price Rules is visible`).toBeVisible(); + }).toPass(); + + await cartPriceRulesLink.click(); + + const addCartPriceRuleButton = this.page.getByRole('button', {name: UIReference.cartPriceRulesPage.addCartPriceRuleButtonLabel}); + await addCartPriceRuleButton.waitFor(); + + // Use search field to check for coupon codes. + const couponSearchField = this.page.locator(UIReference.adminPage.couponSearchFieldLocator); + await couponSearchField.fill(magentoCouponCode); + await this.page.getByRole('button', {name: UIReference.general.searchButtonLabel}).click(); + + await expect(this.page.getByText(UIReference.adminPage.searchResultsText), `Search results text visible`).toBeVisible(); + + const couponCellField = this.page.getByRole('cell', { name: outcomeMarker.magentoAdmin.noResultsFoundText }); + + if(await couponCellField.isHidden()){ + const couponStatusField = this.page.locator('tr').filter({hasText:magentoCouponCode}).first(); + const couponStatus = await couponStatusField.innerText(); + if(couponStatus.includes(UIReference.cartPriceRulesPage.couponCodeActiveStatusText)){ + resultMessage = 'Coupon already exists and is active.'; + } else { + // coupon has been found, but is not active. + await couponStatusField.click(); + const activeStatusSwitcher = this.page.locator(UIReference.cartPriceRulesPage.activeStatusSwitcherLocator).first(); + const activeStatusLabel = this.page.locator(UIReference.cartPriceRulesPage.activeStatusLabelLocator).first(); + + await expect(activeStatusLabel, `Active/Disable toggle is visible`).toBeVisible(); + await activeStatusSwitcher.click(); + + const saveCouponButton = this.page.getByRole('button', {name:UIReference.cartPriceRulesPage.saveRuleButtonLabel, exact:true}); + await saveCouponButton.click(); + + await expect(this.page.locator( + UIReference.general.messageLocator).filter({hasText: outcomeMarker.magentoAdmin.couponRuleSavedText} + ), "Message 'you saved the rule' is visible").toBeVisible(); + resultMessage = `Coupon code ${magentoCouponCode} has been activated.`; + } + } else { + // coupon is not set + await addCartPriceRuleButton.click(); + + const websiteSelector = this.page.getByLabel(UIReference.cartPriceRulesPage.websitesSelectLabel); + await websiteSelector.evaluate(select => { + const s = select as HTMLSelectElement; + for (const option of s.options) { + option.selected = true; + } + select.dispatchEvent(new Event('change')); + }); + + const customerGroupsSelector = this.page.getByLabel(UIReference.cartPriceRulesPage.customerGroupsSelectLabel, { exact: true }); + await customerGroupsSelector.evaluate(select => { + const s = select as HTMLSelectElement; + for (const option of s.options) { + option.selected = true; + } + select.dispatchEvent(new Event('change')); + }); + + await this.page.getByRole('textbox', { name: UIReference.cartPriceRulesPage.ruleNameFieldLabel }).fill(magentoCouponCode); + await this.page.locator(UIReference.cartPriceRulesPage.couponTypeSelectField).selectOption({ label: inputValues.coupon.couponType }); + await this.page.getByLabel(UIReference.cartPriceRulesPage.couponCodeFieldLabel).fill(magentoCouponCode); + + await this.page.getByText(UIReference.cartPriceRulesPage.actionsSubtitleLabel, { exact: true }).click(); + await this.page.getByLabel(UIReference.cartPriceRulesPage.discountAmountFieldLabel).fill('10'); + + const couponSaveButton = this.page.getByRole('button', { name: UIReference.cartPriceRulesPage.saveRuleButtonLabel, exact: true }); + await couponSaveButton.scrollIntoViewIfNeeded(); + await couponSaveButton.click({force:true}); + await expect(this.page.locator( + UIReference.general.messageLocator).filter({hasText: outcomeMarker.magentoAdmin.couponRuleSavedText} + ), "Message 'you saved the rule' is visible").toBeVisible(); + resultMessage = `Coupon code ${magentoCouponCode} has been set and activated.`; + } + + // Clear the search field + await couponSearchField.waitFor(); + const clearSearchButton = this.page.getByRole('button', { name: UIReference.cartPriceRulesPage.clearSearchButtonLabel }); + await clearSearchButton.click(); + await expect(couponSearchField, `Coupon Code search field is empty`).toBeEmpty(); + + return resultMessage; + }; +} + +export default AdminMarketing; \ No newline at end of file diff --git a/dev/tests/e2e/tests/poms/adminhtml/orders.page.ts b/dev/tests/e2e/tests/poms/adminhtml/orders.page.ts new file mode 100644 index 00000000000..0b2ebb2d41b --- /dev/null +++ b/dev/tests/e2e/tests/poms/adminhtml/orders.page.ts @@ -0,0 +1,58 @@ +// @ts-check + +import { expect, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +class AdminOrders { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * @feature Navigate to Admin orders page + * @scenario User navigates to the admin orders page + * @given + * @when I navigate to the orders page + * @then I should see the orders list + * @and I should see the saved order number id + */ + async checkIfOrderExists(orderNumber: string){ + const mainMenuSalesButton = this.page.getByRole('link', { name: UIReference.adminPage.navigation.salesButtonLabel }); + const ordersButtonLink = this.page.getByRole('link', { name: UIReference.adminPage.subNavigation.ordersButtonLabel }).first(); + + await expect(async () => { + await mainMenuSalesButton.click(); + await expect(ordersButtonLink).toBeVisible(); + }).toPass(); + + await ordersButtonLink.click(); + + const ordersSearchField = this.page.getByRole('textbox', {name: UIReference.adminGeneral.tableSearchFieldLabel}); + + // Wait for URL. If loading symbol is visible, wait for it to go away + await this.page.waitForURL(`**/${requireEnv('MAGENTO_ADMIN_SLUG')}/sales/order/index/**`); + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + await ordersSearchField.waitFor(); + await ordersSearchField.fill(orderNumber); + await this.page.getByRole('button', {name: UIReference.adminGeneral.searchButtonLabel}).click(); + + if (await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).isVisible()) { + await this.page.locator(UIReference.adminGeneral.loadingSpinnerLocator).waitFor({state: 'hidden'}); + } + + // Loop to ensure the 'results found' text is visible + await expect(async() =>{ + await this.page.getByText(outcomeMarker.adminGeneral.searchResultsFoundText).first(); + }).toPass(); + + await expect(this.page.getByRole('cell', {name:orderNumber}).locator('div')).toBeVisible(); + } +} + +export default AdminOrders; \ No newline at end of file diff --git a/dev/tests/e2e/tests/poms/frontend/account.page.ts b/dev/tests/e2e/tests/poms/frontend/account.page.ts new file mode 100644 index 00000000000..5f6537e279d --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/account.page.ts @@ -0,0 +1,311 @@ +// @ts-check + +import {expect, type Locator, type Page, test, TestInfo} from '@playwright/test'; +import {faker, th} from '@faker-js/faker'; +import { UIReference, outcomeMarker, inputValues, slugs } from '@config'; + +import LoginPage from '@poms/frontend/login.page'; + +class AccountPage { + readonly page: Page; + readonly accountDashboardTitle: Locator; + readonly firstNameField: Locator; + readonly lastNameField: Locator; + readonly companyNameField: Locator; + readonly phoneNumberField: Locator; + readonly loginPage: LoginPage; + readonly streetAddressField: Locator; + readonly zipCodeField: Locator; + readonly cityField: Locator; + readonly countrySelectorField: Locator; + readonly stateSelectorField: Locator; + readonly stateInputField: Locator; + readonly saveAddressButton: Locator; + readonly addNewAddressButton: Locator; + readonly deleteAddressButton: Locator; + readonly editAddressButton: Locator; + readonly changePasswordSwitch: Locator; + readonly changeEmailCheck: Locator; + readonly currentPasswordField: Locator; + readonly newPasswordField: Locator; + readonly confirmNewPasswordField: Locator; + readonly genericSaveButton: Locator; + readonly accountCreationFirstNameField: Locator; + readonly accountCreationLastNameField: Locator; + readonly accountCreationEmailField: Locator; + readonly accountCreationPasswordField: Locator; + readonly accountCreationPasswordRepeatField: Locator; + readonly accountCreationConfirmButton: Locator; + readonly accountInformationField: Locator; + + constructor(page: Page) { + this.page = page; + this.loginPage = new LoginPage(page); + + this.accountDashboardTitle = page.getByRole('heading', { name: UIReference.accountDashboard.accountDashboardTitleLabel }); + this.firstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel); + this.lastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel); + // this.companyNameField = page.getByLabel(UIReference.newAddress.companyNameLabel); + this.companyNameField = page.getByRole('textbox', {name: UIReference.newAddress.companyNameLabel}); + this.phoneNumberField = page.getByLabel(UIReference.newAddress.phoneNumberLabel); + this.streetAddressField = page.getByLabel(UIReference.newAddress.streetAddressLabel, { exact: true }); + this.zipCodeField = page.getByLabel(UIReference.newAddress.zipCodeLabel); + this.cityField = page.getByLabel(UIReference.newAddress.cityNameLabel); + this.countrySelectorField = page.getByLabel(UIReference.newAddress.countryLabel); + + this.stateInputField = page.getByLabel(UIReference.newAddress.provinceSelectLabel); + this.stateSelectorField = this.stateInputField.filter({ hasText: UIReference.newAddress.provinceSelectFilterLabel }); + + this.saveAddressButton = page.getByRole('button', { name: UIReference.newAddress.saveAdressButton }); + + // Account Information elements + //this.changePasswordSwitch = page.getByRole('switch', { name: UIReference.personalInformation.changePasswordSwitchLabel }); + this.changePasswordSwitch = page.getByRole('checkbox', {name: UIReference.personalInformation.changePasswordSwitchLabel}); + //this.changeEmailCheck = page.getByRole('switch', { name: UIReference.personalInformation.changeEmailCheckLabel }); + this.changeEmailCheck = page.getByRole('checkbox', {name: UIReference.personalInformation.changeEmailCheckLabel}); + this.currentPasswordField = page.getByLabel(UIReference.credentials.currentPasswordFieldLabel); + this.newPasswordField = page.getByLabel(UIReference.credentials.newPasswordFieldLabel, { exact: true }); + this.confirmNewPasswordField = page.getByLabel(UIReference.credentials.newPasswordConfirmFieldLabel); + this.genericSaveButton = page.getByRole('button', { name: UIReference.general.genericSaveButtonLabel }); + + // Account Creation elements + this.accountCreationFirstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel); + this.accountCreationLastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel); + this.accountCreationEmailField = page.getByLabel(UIReference.credentials.emailFieldLabel, { exact: true }); + this.accountCreationPasswordField = page.getByLabel(UIReference.credentials.passwordFieldLabel, { exact: true }); + this.accountCreationPasswordRepeatField = page.getByLabel(UIReference.credentials.passwordConfirmFieldLabel); + //this.accountCreationConfirmButton = page.getByRole('button', { name: UIReference.accountCreation.createAccountButtonLabel }); + const form = page.locator('#form-validate'); + this.accountCreationConfirmButton = form.locator('button[type="submit"]'); + + this.accountInformationField = page.locator(UIReference.accountDashboard.accountInformationFieldLocator).first(); + + // Address Book elements + this.addNewAddressButton = page.getByRole('button', { name: UIReference.accountDashboard.addAddressButtonLabel }); + this.deleteAddressButton = page.getByRole('link', { name: UIReference.accountDashboard.addressDeleteIconButton }).first(); + this.editAddressButton = page.getByRole('link', { name: UIReference.accountDashboard.editAddressIconButton }).first(); + } + + /** + * Add an address to test account + * @param values - Optional values to fill the form with + */ + async addNewAddress(values?: { + company?: string; + phone?: string; + street?: string; + zip?: string; + city?: string; + state?: string; + country?: string; + }) { + let addressAddedNotification = outcomeMarker.address.newAddressAddedNotifcation; + + await expect(this.firstNameField, `first name should be pre-filled`).not.toBeEmpty(); + await expect(this.lastNameField, `last name should be pre-filled`).not.toBeEmpty(); + + const phone = values?.phone || faker.phone.number({style: 'national'}); // Use 'national' style to prevent input errors + const streetName = values?.street || faker.location.streetAddress(); + const zipCode = values?.zip || faker.location.zipCode(); + const cityName = values?.city || faker.location.city(); + const stateName = values?.state || faker.location.state(); + const country = values?.country || faker.helpers.arrayElement(inputValues.addressCountries); + if (values?.company) { + await this.companyNameField.fill(values.company); + } + + await this.phoneNumberField.fill(phone); + await this.streetAddressField.fill(streetName); + await this.zipCodeField.fill(zipCode); + await this.cityField.fill(cityName); + + // If default selected country == country we want to use for the test, + // don't re-select it. + const defaultSelectedCountry = await this.countrySelectorField.evaluate( + (select: HTMLSelectElement) => select.options[select.selectedIndex]?.text + ); + + if(country !== defaultSelectedCountry) { + await this.countrySelectorField.selectOption({label: country}); + } + const regionDropdown = this.page.locator(UIReference.newAddress.regionDropdownLocator); + const regionInputField = this.page.getByRole('textbox', {name: UIReference.newAddress.provinceSelectLabel}); + + if(country !== 'United States') { + await expect(regionDropdown, `Dropdown should not be visible`).toBeHidden(); + await expect(regionInputField, `Region input field should be visible`).toBeVisible(); + + await regionInputField.fill(stateName); + } else { + await expect(regionInputField, `Dropdown should not be visible`).toBeHidden(); + await expect(regionDropdown, `State input field should be editable`).toBeEditable(); + // await regionDropdown.selectOption(stateName); + await this.stateSelectorField.selectOption(stateName); + // Timeout because Alpine uses an @input.debounce to delay the activation of the event + // Standard debounce is 250ms. + await this.page.waitForTimeout(1000); + } + + await this.saveAddressButton.scrollIntoViewIfNeeded(); + await this.saveAddressButton.click(); + await this.page.waitForLoadState(); + + await expect.soft(this.page.getByText(addressAddedNotification), `message that confirms actions should be visible`).toBeVisible(); + } + + + + async editExistingAddress(values?: { + firstName?: string; + lastName?: string; + company?: string; + phone?: string; + street?: string; + zip?: string; + city?: string; + state?: string; + country?: string; + }, defaultAddress: boolean = false) { + let addressModifiedNotification = outcomeMarker.address.newAddressAddedNotifcation; + + const firstName = values?.firstName || faker.person.firstName(); + const lastName = values?.lastName || faker.person.lastName(); + const companyName = values?.company || faker.company.name(); + const phone = values?.phone || faker.phone.number({style: 'national'}); // Use 'national' style to prevent input errors + const streetName = values?.street || faker.location.streetAddress(); + const zipCode = values?.zip || faker.location.zipCode(); + const cityName = values?.city || faker.location.city(); + const stateName = values?.state || faker.location.state(); + const country = values?.country || faker.helpers.arrayElement(inputValues.addressCountries); + + // click the correct button based on if there's more than one address (defaultAddress boolean) + defaultAddress ? await this.page.getByRole('link', { name: 'Change Shipping Address' }).click() : await this.editAddressButton.click(); + + let oldAddress = await this.streetAddressField.inputValue(); + + await expect(this.firstNameField,`first name field should be filled in automatically`).not.toBeEmpty(); + await expect(this.lastNameField, `first name field should be filled in automatically`).not.toBeEmpty(); + + // contact information section + await this.firstNameField.fill(firstName); + await this.lastNameField.fill(lastName); + await this.companyNameField.fill(companyName); + await this.phoneNumberField.fill(phone); + + // Address information section + await this.streetAddressField.fill(streetName); + await this.zipCodeField.fill(zipCode); + await this.cityField.fill(cityName); + + // If default selected country == country we want to use for the test, + // don't re-select it. + const defaultSelectedCountry = await this.countrySelectorField.evaluate( (select: HTMLSelectElement) => select.options[select.selectedIndex]?.text); + if(country !== defaultSelectedCountry) { + await this.countrySelectorField.selectOption({label: country}); + } + + const regionDropdown = this.page.locator(UIReference.newAddress.regionDropdownLocator); + const regionInputField = this.page.getByRole('textbox', {name: UIReference.newAddress.provinceSelectLabel}); + + if(country !== 'United States') { + await expect(regionDropdown, `Dropdown should not be visible`).toBeHidden(); + await expect(regionInputField, `Region input field should be visible`).toBeVisible(); + + await regionInputField.fill(stateName); + } else { + // await regionDropdown.selectOption(stateName); + await this.stateSelectorField.selectOption(stateName); + // Timeout because Alpine uses an @input.debounce to delay the activation of the event + // Standard debounce is 250ms. + await this.page.waitForTimeout(1000); + } + + await this.saveAddressButton.scrollIntoViewIfNeeded(); + await this.saveAddressButton.click(); + await this.page.waitForLoadState(); + + await expect.soft(this.page.getByText(addressModifiedNotification)).toBeVisible(); + // await expect(this.page.getByText(streetName).last()).toBeVisible(); + if (oldAddress != null) await expect(this.page.getByText(oldAddress)).not.toBeVisible(); + } + + async deleteFirstAddressFromAddressBook() { + let addressDeletedNotification = outcomeMarker.address.addressDeletedNotification; + let addressBookSection = this.page.locator(UIReference.accountDashboard.addressBookArea); + + this.page.on('dialog', async (dialog) => { + if (dialog.type() === 'confirm') { + await dialog.accept(); + } + }); + + // Retrieve all text in the 'address book' section + let addressBookArray = await addressBookSection.allInnerTexts(); + // split by each new line + let arraySplit = addressBookArray[0].split('\n'); + // Retrieve index 8, because: + // index 0 to 5 are the table headers (i.e. Company, Name etc.) + // index 6 is company, index 7 is name, and index 8 is the first address value. + // if this table changes, the index number should change. + let addressToBeDeleted = arraySplit[8]; + + // Annotate the report so the user knows what address should be deleted + test.info().annotations.push({type: `Address to be deleted`, description: addressToBeDeleted}); + + await this.deleteAddressButton.click(); + await this.page.waitForLoadState(); + + await expect(this.page.getByText(addressDeletedNotification)).toBeVisible(); + // Left out to deprioritise this to be fixed + //await expect(addressBookSection, `${addressToBeDeleted} should not be visible`).not.toContainText(addressToBeDeleted); + } + + async updatePassword(currentPassword: string, newPassword: string) { + let passwordUpdatedNotification = outcomeMarker.account.changedPasswordNotificationText; + await this.changePasswordSwitch.check(); + await this.currentPasswordField.fill(currentPassword); + await this.newPasswordField.fill(newPassword); + await this.confirmNewPasswordField.fill(newPassword); + await this.genericSaveButton.click(); + + await this.page.waitForURL(slugs.account.loginSlug); + await expect(this.page.getByText(passwordUpdatedNotification)).toBeVisible(); + } + + async updateEmail(currentPassword: string, newEmail: string) { + let accountUpdatedNotification = outcomeMarker.account.changedPasswordNotificationText; + await this.changeEmailCheck.check(); + await this.accountCreationEmailField.fill(newEmail); + await this.currentPasswordField.fill(currentPassword); + await this.genericSaveButton.click(); + await this.page.waitForLoadState(); + await this.loginPage.login(newEmail, currentPassword); + //await expect(this.accountInformationField, `Account information should contain email: ${newEmail}`).toContainText(newEmail); + + // this part below should be contained in one place and reused in RegisterPage.createNewAccount + const accountInfoBlock = this.page.locator('.main'); + await expect(this.page.getByRole('heading', { name: 'Account Information' }), 'Account Information block title should be visible'); + const contactInfoBox = accountInfoBlock.locator('.box.box-information'); + const contactInfoContent = contactInfoBox.locator('.box-content'); + await expect(contactInfoContent, `Account information should contain email: ${newEmail}`) + .toContainText(newEmail); + } + + async deleteAllAddresses() { + let addressDeletedNotification = outcomeMarker.address.addressDeletedNotification; + + this.page.on('dialog', async (dialog) => { + if (dialog.type() === 'confirm') { + await dialog.accept(); + } + }); + + while (await this.deleteAddressButton.isVisible()) { + await this.deleteAddressButton.click(); + await this.page.waitForLoadState(); + await expect.soft(this.page.getByText(addressDeletedNotification)).toBeVisible(); + } + } +} + +export default AccountPage; diff --git a/dev/tests/e2e/tests/poms/frontend/category.page.ts b/dev/tests/e2e/tests/poms/frontend/category.page.ts new file mode 100644 index 00000000000..7eac4c9f660 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/category.page.ts @@ -0,0 +1,144 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, slugs } from '@config'; + +class CategoryPage { + readonly page:Page; + categoryPageTitle: Locator; + + constructor(page: Page) { + this.page = page; + this.categoryPageTitle = this.page.getByRole('heading', { name: UIReference.categoryPage.categoryPageTitleText }); + } + + /** + * @feature Navigate to Category page + * @scenario User navigates to the category page + * @given + * @when I navigate to the category page + * @then I should see the filter options + * @and I should see the title of the page + */ + async goToCategoryPage(){ + await this.page.goto(slugs.categoryPage.categorySlug); + const filters = this.page.getByRole('region', { name: 'Product filters' }); + const firstFilterButton = filters.locator('.filter-options-title').first(); + + await expect(firstFilterButton).toBeVisible(); + await firstFilterButton.click(); + + this.page.waitForLoadState(); + await expect(this.categoryPageTitle).toBeVisible(); + } + + /** + * @feature Filter category page + * @scenario User filters category page on size L + * @given I am on the category page + * @when I open the Size filter category + * @and I click the size L button + * @then the URL should reflect this filter + * @and I should see fewer products + */ + async filterOnSize() { + const sizeFilterButton = this.page.getByRole('button', {name: UIReference.categoryPage.sizeFilterButtonLabel}); + const contentId = await sizeFilterButton.getAttribute('aria-controls'); + const sizeFilterContent = this.page.locator(`#${contentId}`); + await expect(sizeFilterContent).toBeVisible(); + + const removeActiveFilterLink = this.page.getByRole('link', {name: UIReference.categoryPage.removeActiveFilterButtonLabel}).first(); + const amountOfItemsBeforeFilter = parseInt(await this.page.locator(UIReference.categoryPage.itemsOnPageAmountLocator).last().innerText()); + + await expect(async() => { + await sizeFilterButton.click(); + await expect(sizeFilterContent).toBeVisible(); + }).toPass(); + + const sizeLSwatch = sizeFilterContent.locator( + '.swatch-option[data-option-label="L"]' + ); + + await expect(sizeLSwatch).toBeVisible(); + await sizeLSwatch.click(); + + const amountOfItemsAfterFilter = parseInt(await this.page.locator(UIReference.categoryPage.itemsOnPageAmountLocator).last().innerText()); + await expect(removeActiveFilterLink, 'Trash button to remove filter is visible').toBeVisible(); + expect(amountOfItemsAfterFilter, `Amount of items shown with filter (${amountOfItemsAfterFilter}) is less than without (${amountOfItemsBeforeFilter})`).toEqual(amountOfItemsBeforeFilter); + } + + /** + * @feature Sort category page by price + * @scenario User sorts category page by price + * @given I am on the category page + * @when I open the 'Sort' dropdown + * @and I click the price button + * @then the URL should reflect this filter + * @and I should see products sorted by price + */ + async sortProducts(attribute:string){ + const sortButton = this.page.getByLabel(UIReference.categoryPage.sortByButtonLabel); + await sortButton.selectOption(attribute); + const sortRegex = new RegExp(`\\?product_list_order=${attribute}$`); + await this.page.waitForURL(sortRegex); + + //const selectedValue = await this.page.$eval(UIReference.categoryPage.sortByButtonLocator, sel => (sel as HTMLSelectElement).value); + const sorter = this.page.locator('select[data-role="sorter"]'); + const selectedValue = await sorter.inputValue(); + + // sortButton should now display attribute + expect(selectedValue, `Sort button should now display ${attribute}`).toEqual(attribute); + // URL now has ?product_list_order=${attribute} + expect(this.page.url(), `URL should contain ?product_list_order=${attribute}`).toContain(`product_list_order=${attribute}`); + } + + /** + * @feature products per page + * @scenario User updates the amount of products shown on the page + * @given I am on the category page + * @when I change the 'Show' dropdown + * @then the URl should reflect this filter + * @and the amount of items should be the new amount I've selected + */ + async showMoreProducts(){ + const itemsPerPageButton = this.page.getByLabel(UIReference.categoryPage.itemsPerPageButtonLabel); + const productGrid = this.page.locator(UIReference.categoryPage.productGridLocator); + + await itemsPerPageButton.selectOption('48'); + const itemsRegex = /\?product_list_limit=48$/; + await this.page.waitForURL(itemsRegex); + + const amountOfItems = await productGrid.locator('li').count(); + + expect(this.page.url(), `URL should contain ?product_list_limit=36`).toContain(`?product_list_limit=36`); + expect(amountOfItems, `Amount of items on the page should be 36`).toBe(36); + } + + /** + * @feature View switcher + * @scenario User switches from the grid to the list view + * @given I am on the category page + * @when I click the grid or list mode button + * @then the URl should reflect this updated view + * @and the reported selected view should not be the same as it was before I clicked the button + */ + async switchView(){ + const viewSwitcher = this.page.getByLabel(UIReference.categoryPage.viewSwitchLabel, {exact: true}).locator(UIReference.categoryPage.activeViewLocator); + const activeView = await viewSwitcher.getAttribute('title'); + + if(activeView == 'Grid'){ + await this.page.getByLabel(UIReference.categoryPage.viewListLabel).click(); + } else { + await this.page.getByLabel(UIReference.categoryPage.viewGridLabel).click(); + } + + const viewRegex = /\?product_list_mode=list$/; + await this.page.waitForURL(viewRegex); + + const newActiveView = await viewSwitcher.getAttribute('title'); + expect(newActiveView, `View (now ${newActiveView}) should be switched (old: ${activeView})`).not.toEqual(activeView); + expect(this.page.url(),`URL should contain ?product_list_mode=${newActiveView?.toLowerCase()}`).toContain(`?product_list_mode=${newActiveView?.toLowerCase()}`); + } +} + +export default CategoryPage; diff --git a/dev/tests/e2e/tests/poms/frontend/checkout.page.ts b/dev/tests/e2e/tests/poms/frontend/checkout.page.ts new file mode 100644 index 00000000000..266a7c77712 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/checkout.page.ts @@ -0,0 +1,261 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { faker } from '@faker-js/faker'; +import { UIReference, outcomeMarker, slugs, inputValues } from '@config'; +import MagewireUtils from '@utils/magewire.utils'; + +class CheckoutPage extends MagewireUtils { + + readonly shippingMethodOptionFixed: Locator; + readonly shippingMethodTableRateFixed: Locator; + readonly paymentMethodOptionCheck: Locator; + readonly showDiscountFormButton: Locator; + readonly placeOrderButton: Locator; + readonly continueShoppingButton: Locator; + readonly subtotalElement: Locator; + readonly shippingElement: Locator; + readonly taxElement: Locator; + readonly grandTotalElement: Locator; + readonly paymentMethodOptionCreditCard: Locator; + readonly paymentMethodOptionPaypal: Locator; + readonly creditCardNumberField: Locator; + readonly creditCardExpiryField: Locator; + readonly creditCardCVVField: Locator; + readonly creditCardNameField: Locator; + + constructor( + page: Page + ){ + super(page); + this.shippingMethodOptionFixed = this.page.getByLabel(UIReference.checkout.shippingMethodFixedLabel); + this.shippingMethodTableRateFixed = this.page.getByLabel(UIReference.checkout.shippingMethodTableRateLabel); + this.paymentMethodOptionCheck = this.page.getByLabel(UIReference.checkout.paymentOptionCheckLabel); + this.showDiscountFormButton = this.page.getByRole('button', {name: UIReference.checkout.openDiscountFormLabel}); + this.placeOrderButton = this.page.getByRole('button', { name: UIReference.checkout.placeOrderButtonLabel }); + this.continueShoppingButton = this.page.getByRole('link', { name: UIReference.checkout.continueShoppingLabel }); + // this.subtotalElement = page.getByText('Subtotal $'); + this.subtotalElement = page.getByText(`${UIReference.financial.subTotal} ${UIReference.general.genericPriceSymbol}`); + // this.shippingElement = page.getByText('Shipping & Handling (Flat Rate - Fixed) $'); + this.shippingElement = page.getByText(`${UIReference.checkout.shippingPriceText} ${UIReference.general.genericPriceSymbol}`); + // this.taxElement = page.getByText('Tax $'); + this.taxElement = page.getByText(`${UIReference.checkout.taxPriceText} ${UIReference.general.genericPriceSymbol}`); + // this.grandTotalElement = page.getByText('Grand Total $'); + this.grandTotalElement = page.getByText(`${UIReference.financial.grandTotal} ${UIReference.general.genericPriceSymbol}`); + this.paymentMethodOptionCreditCard = this.page.getByLabel(UIReference.checkout.paymentOptionCreditCardLabel); + this.paymentMethodOptionPaypal = this.page.getByLabel(UIReference.checkout.paymentOptionPaypalLabel); + this.creditCardNumberField = this.page.getByLabel(UIReference.checkout.creditCardNumberLabel); + this.creditCardExpiryField = this.page.getByLabel(UIReference.checkout.creditCardExpiryLabel); + this.creditCardCVVField = this.page.getByLabel(UIReference.checkout.creditCardCVVLabel); + this.creditCardNameField = this.page.getByLabel(UIReference.checkout.creditCardNameLabel); + } + + // ============================================== + // Order-related methods + // ============================================== + + async placeOrder(){ + let orderPlacedNotification = outcomeMarker.checkout.orderPlacedNotification; + + // If we're not already on the checkout page, go there + if (!this.page.url().includes(slugs.checkout.checkoutSlug)) { + await this.page.goto(slugs.checkout.checkoutSlug); + } + + // If shipping method is not selected, select it + if (!(await this.shippingMethodOptionFixed.isChecked())) { + await this.shippingMethodOptionFixed.check(); + await this.waitForMagewireRequests(); + } + + await this.paymentMethodOptionCheck.check(); + await this.waitForMagewireRequests(); + + await this.placeOrderButton.click(); + await this.waitForMagewireRequests(); + + await this.page.waitForURL(slugs.checkout.purchaseSuccessSlug); + + await expect.soft(this.page.getByText(orderPlacedNotification)).toBeVisible(); + let orderNumber = await this.page.locator('p').filter({ hasText: outcomeMarker.checkout.orderPlacedNumberText }); + + await expect(this.continueShoppingButton, `${outcomeMarker.checkout.orderPlacedNumberText} ${orderNumber}`).toBeVisible(); + return orderNumber; + } + + + // ============================================== + // Discount-related methods + // ============================================== + + async applyDiscountCodeCheckout(code: string){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountFormButton.click(); + await this.waitForMagewireRequests(); + } + + if(await this.page.getByText(outcomeMarker.cart.priceReducedSymbols).isVisible()){ + // discount is already active. + let cancelCouponButton = this.page.getByRole('button', { name: UIReference.checkout.cancelDiscountButtonLabel }); + await cancelCouponButton.click(); + await this.waitForMagewireRequests(); + } + + let applyCouponCheckoutButton = this.page.getByRole('button', { name: UIReference.checkout.applyDiscountButtonLabel }); + let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel); + + await checkoutDiscountField.fill(code); + await applyCouponCheckoutButton.click(); + await this.waitForMagewireRequests(); + + await expect.soft(this.page.getByText(`${outcomeMarker.checkout.couponAppliedNotification}`),`Notification that discount code ${code} has been applied`).toBeVisible({timeout: 30000}); + await expect(this.page.getByText(outcomeMarker.checkout.checkoutPriceReducedSymbol),`'-$' should be visible on the page`).toBeVisible(); + } + + async enterWrongCouponCode(code: string){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountFormButton.click(); + await this.waitForMagewireRequests(); + } + + let applyCouponCheckoutButton = this.page.getByRole('button', { name: UIReference.checkout.applyDiscountButtonLabel }); + let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel); + await checkoutDiscountField.fill(code); + await applyCouponCheckoutButton.click(); + await this.waitForMagewireRequests(); + + await expect.soft(this.page.getByText(outcomeMarker.checkout.incorrectDiscountNotification), `Code should not work`).toBeVisible(); + await expect(checkoutDiscountField).toBeEditable(); + } + + async removeDiscountCode(){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountFormButton.click(); + await this.waitForMagewireRequests(); + } + + let cancelCouponButton = this.page.getByRole('button', {name: UIReference.cart.cancelCouponButtonLabel}); + await cancelCouponButton.click(); + await this.waitForMagewireRequests(); + + await expect.soft(this.page.getByText(outcomeMarker.checkout.couponRemovedNotification),`Notification should be visible`).toBeVisible(); + await expect(this.page.getByText(outcomeMarker.checkout.checkoutPriceReducedSymbol),`'-$' should not be on the page`).toBeHidden(); + + let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel); + await expect(checkoutDiscountField).toBeEditable(); + } + + // ============================================== + // Price summary methods + // ============================================== + + async getPriceValue(element: Locator): Promise { + const priceText = await element.innerText(); + // Extract just the price part after the $ symbol + const match = priceText.match(/\$\s*([\d.]+)/); + return match ? parseFloat(match[1]) : 0; + } + + async verifyPriceCalculations() { + const subtotal = await this.getPriceValue(this.subtotalElement); + const shipping = await this.getPriceValue(this.shippingElement); + const tax = await this.getPriceValue(this.taxElement); + const grandTotal = await this.getPriceValue(this.grandTotalElement); + + const calculatedTotal = +(subtotal + shipping + tax).toFixed(2); + + expect(subtotal, `Subtotal (${subtotal}) should be greater than 0`).toBeGreaterThan(0); + expect(shipping, `Shipping cost (${shipping}) should be greater than 0`).toBeGreaterThan(0); + // Enable when tax settings are set. + //expect(tax, `Tax (${tax}) should be greater than 0`).toBeGreaterThan(0); + expect(grandTotal, `Grand total (${grandTotal}) should equal calculated total (${calculatedTotal})`).toBe(calculatedTotal); + } + + async selectPaymentMethod(method: 'check' | 'creditcard' | 'paypal'): Promise { + switch(method) { + case 'check': + await this.paymentMethodOptionCheck.check(); + break; + case 'creditcard': + await this.paymentMethodOptionCreditCard.check(); + // Fill credit card details + await this.creditCardNumberField.fill(inputValues.payment?.creditCard?.number || '4111111111111111'); + await this.creditCardExpiryField.fill(inputValues.payment?.creditCard?.expiry || '12/25'); + await this.creditCardCVVField.fill(inputValues.payment?.creditCard?.cvv || '123'); + await this.creditCardNameField.fill(inputValues.payment?.creditCard?.name || 'Test User'); + break; + case 'paypal': + await this.paymentMethodOptionPaypal.check(); + break; + } + + await this.waitForMagewireRequests(); + } + + async selectShippingMethod(method: 'fixed' | 'table rate'): Promise { + switch(method) { + case 'fixed': + await this.shippingMethodOptionFixed.check(); + break; + case 'table rate': + await this.shippingMethodTableRateFixed.check(); + break; + } + + await this.waitForMagewireRequests(); + } + + async fillShippingAddress() { + // Fill required shipping address fields + await this.page.getByLabel(UIReference.credentials.emailCheckoutFieldLabel, { exact: true }).fill(faker.internet.email()); + await this.page.getByLabel(UIReference.personalInformation.firstNameLabel).fill(faker.person.firstName()); + await this.page.getByLabel(UIReference.personalInformation.lastNameLabel).fill(faker.person.lastName()); + await this.page.getByLabel(UIReference.newAddress.streetAddressLabel).first().fill(faker.location.streetAddress()); + await this.page.getByLabel(UIReference.newAddress.zipCodeLabel).fill(faker.location.zipCode()); + await this.page.getByLabel(UIReference.newAddress.cityNameLabel).fill(faker.location.city()); + await this.page.getByLabel(UIReference.newAddress.phoneNumberLabel).fill(faker.phone.number({style: 'national'})); + + // Select country (if needed) + // await this.page.getByLabel('Country').selectOption('US'); + const country = faker.helpers.arrayElement(inputValues.addressCountries); + const countrySelectorField = this.page.getByLabel(UIReference.newAddress.countryLabel); + const stateInputField = this.page.getByRole('textbox', { name: UIReference.newAddress.provinceSelectLabel }); + const stateSelectorField = stateInputField.filter({ hasText: UIReference.newAddress.provinceSelectFilterLabel }); + + + // If default selected country == country we want to use for the test, + // don't re-select it. + const defaultSelectedCountry = await countrySelectorField.evaluate( + (select: HTMLSelectElement) => select.options[select.selectedIndex]?.text + ); + + if(country !== defaultSelectedCountry) { + await countrySelectorField.selectOption({label: country}); + } + + const regionDropdown = this.page.getByLabel(UIReference.newAddress.provinceSelectLabel); + const regionInputField = this.page.getByRole('textbox', {name: UIReference.newAddress.provinceSelectLabel}); + + // Select state + if(country !== 'United States') { + // await expect(regionDropdown, `Dropdown should not be visible`).toBeHidden(); + await expect(regionInputField, `State input field should be editable`).toBeEditable(); + await regionInputField.fill(faker.location.state()); + } else { + await expect(regionInputField, `Dropdown should not be visible`).toBeHidden(); + // await expect(regionDropdown, `State input field should be editable`).toBeEditable(); + await regionDropdown.selectOption(faker.location.state()); + // Timeout because Alpine uses an @input.debounce to delay the activation of the event + // Standard debounce is 250ms. + await this.page.waitForTimeout(1000); + } + + // Wait for any Magewire updates + await this.waitForMagewireRequests(); + } +} + +export default CheckoutPage; diff --git a/dev/tests/e2e/tests/poms/frontend/compare.page.ts b/dev/tests/e2e/tests/poms/frontend/compare.page.ts new file mode 100644 index 00000000000..2ba1b176b92 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/compare.page.ts @@ -0,0 +1,47 @@ +// @ts-check + +import { expect, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker } from '@config'; + +class ComparePage { + page: Page; + + constructor(page: Page) { + this.page = page; + } + + async removeProductFromCompare(product:string){ + let comparisonPageEmptyText = this.page.getByText(UIReference.comparePage.comparisonPageEmptyText); + // if the comparison page is empty, we can't remove anything + if (await comparisonPageEmptyText.isVisible()) { + return; + } + + let removeFromCompareButton = this.page.getByLabel(`${UIReference.comparePage.removeCompareLabel} ${product}`); + let productRemovedNotification = this.page.getByText(`${outcomeMarker.comparePage.productRemovedNotificationTextOne} ${product} ${outcomeMarker.comparePage.productRemovedNotificationTextTwo}`); + await removeFromCompareButton.click(); + await expect(productRemovedNotification).toBeVisible(); + } + + async addToCart(product:string){ + const successMessage = this.page.locator(UIReference.general.successMessageLocator); + let productAddedNotification = this.page.getByText(`${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`); + + const productCell = this.page.getByRole('cell', {name: product}); + const addToCartButton = productCell.getByRole('button', {name: UIReference.general.addToCartLabel}); + + await addToCartButton.click(); + await successMessage.waitFor(); + await expect(productAddedNotification).toBeVisible(); + } + + async addToWishList(product:string){ + const successMessage = this.page.locator(UIReference.general.successMessageLocator); + let addToWishlistButton = this.page.getByLabel(`${UIReference.comparePage.addToWishListLabel} ${product}`); + let productAddedNotification = this.page.getByText(`${product} ${outcomeMarker.wishListPage.wishListAddedNotification}`); + + await addToWishlistButton.click(); + await successMessage.waitFor(); + } +} +export default ComparePage; diff --git a/dev/tests/e2e/tests/poms/frontend/contact.page.ts b/dev/tests/e2e/tests/poms/frontend/contact.page.ts new file mode 100644 index 00000000000..5325f3475a4 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/contact.page.ts @@ -0,0 +1,43 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { faker } from '@faker-js/faker'; +import { UIReference, outcomeMarker, slugs } from '@config'; + +class ContactPage { + readonly page: Page; + readonly nameField: Locator; + readonly emailField: Locator; + readonly messageField: Locator; + readonly sendFormButton: Locator; + + constructor(page: Page){ + this.page = page; + const form = page.locator('#contact-form'); + this.nameField = this.page.getByLabel(UIReference.credentials.nameFieldLabel); + this.emailField = this.page.getByPlaceholder(UIReference.credentials.emailFieldLabel, { exact: true }); + this.messageField = this.page.locator(UIReference.contactPage.messageFieldSelector); + this.sendFormButton = this.page.getByRole('button', { name: UIReference.general.genericSubmitButtonLabel }); + } + + async fillOutForm(){ + await this.page.goto(slugs.contact.contactSlug); + let messageSentConfirmationText = outcomeMarker.contactPage.messageSentConfirmationText; + + // Add a wait for the form to be visible + await this.nameField.waitFor({state: 'visible', timeout: 10000}); + + await this.nameField.fill(faker.person.firstName()); + await this.emailField.fill(faker.internet.email()); + await this.messageField.fill(faker.lorem.paragraph()); + + await this.sendFormButton.click(); + + await expect(this.page.getByText(messageSentConfirmationText)).toBeVisible(); + await expect(this.nameField, 'name should be empty now').toBeEmpty(); + await expect(this.emailField, 'email should be empty now').toBeEmpty(); + await expect(this.messageField, 'message should be empty now').toBeEmpty(); + } +} + +export default ContactPage; diff --git a/dev/tests/e2e/tests/poms/frontend/footer.page.ts b/dev/tests/e2e/tests/poms/frontend/footer.page.ts new file mode 100644 index 00000000000..63a11167ce2 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/footer.page.ts @@ -0,0 +1,25 @@ +// @ts-check + +import { expect, Locator, type Page } from '@playwright/test'; +import { UIReference } from '@config'; + +class Footer { + readonly page: Page + readonly footerElement: Locator + + + constructor(page: Page) { + this.page = page + this.footerElement = this.page.locator(UIReference.footerPage.footerLocator); + } + + async goToFooterElement () { + await this.page.getByText(UIReference.footerPage.newsletterLabel).scrollIntoViewIfNeeded(); + await expect( + this.footerElement, + 'Footer is visible' + ).toBeVisible(); + } +} + +export default Footer; diff --git a/dev/tests/e2e/tests/poms/frontend/home.page.ts b/dev/tests/e2e/tests/poms/frontend/home.page.ts new file mode 100644 index 00000000000..2d642f28016 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/home.page.ts @@ -0,0 +1,25 @@ +// @ts-check + +import { type Page } from '@playwright/test'; +import { UIReference } from '@config'; + +class HomePage { + + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async addHomepageProductToCart(){ + let buyProductButton = this.page.getByRole('button').filter({hasText: UIReference.general.addToCartLabel}).first(); + + if(await buyProductButton.isVisible()) { + await buyProductButton.click(); + } else { + throw new Error(`No 'Add to Cart' button found on homepage`); + } + } +} + +export default HomePage; diff --git a/dev/tests/e2e/tests/poms/frontend/login.page.ts b/dev/tests/e2e/tests/poms/frontend/login.page.ts new file mode 100644 index 00000000000..9bab26798a2 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/login.page.ts @@ -0,0 +1,54 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, slugs } from '@config'; +import MainmenuPage from '@poms/frontend/mainmenu.page'; + +class LoginPage { + readonly page: Page; + readonly loginEmailField: Locator; + readonly loginPasswordField: Locator; + readonly loginButton: Locator; + + constructor(page: Page) { + this.page = page; + //this.loginEmailField = page.getByRole('textbox', {name: UIReference.credentials.emailFieldLabel, exact: true}); + //this.loginPasswordField = page.getByRole('textbox', {name: UIReference.credentials.passwordFieldLabel}); + this.loginEmailField = page.locator('input[name="login[username]"]'); + this.loginPasswordField = page.locator('input[name="login[password]"]'); + this.loginButton = page.getByRole('button', { name: UIReference.credentials.loginButtonLabel }); + } + + async login(email: string, password: string){ + const mainmenu = new MainmenuPage(this.page); + + await this.page.goto(slugs.account.loginSlug); + await this.loginEmailField.fill(email); + await this.loginPasswordField.fill(password); + // usage of .press("Enter") to prevent webkit issues with button.click(); + await this.loginButton.press("Enter"); + // await this.loginButton.click({force: true}); + + await this.page.waitForURL(slugs.account.accountOverviewSlug); + + // wait for page to be done loading + await this.page.waitForURL('/customer/account/'); + + // Open the menu, then check the 'Sign Out' button is visible + await mainmenu.mainMenuAccountButton.waitFor(); + await mainmenu.mainMenuAccountButton.click(); + await expect(mainmenu.mainMenuLogoutItem, 'Sign Out button is visible, user is logged in').toBeVisible(); + } + + async loginExpectError(email: string, password: string, errorMessage: string) { + await this.page.goto(slugs.account.loginSlug); + await this.loginEmailField.fill(email); + await this.loginPasswordField.fill(password); + await this.loginButton.press('Enter'); + await this.page.waitForLoadState('networkidle'); + + await expect(this.page, 'Should stay on login page').toHaveURL(new RegExp(slugs.account.loginSlug)); + } +} + +export default LoginPage; diff --git a/dev/tests/e2e/tests/poms/frontend/mainmenu.page.ts b/dev/tests/e2e/tests/poms/frontend/mainmenu.page.ts new file mode 100644 index 00000000000..f9b79022c95 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/mainmenu.page.ts @@ -0,0 +1,231 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, slugs } from '@config'; +import { requireEnv } from '@utils/env.utils'; + +class MainMenuPage { + readonly page: Page; + readonly mainMenuElement: Locator; + readonly mainMenuAccountButton: Locator; + readonly mainMenuMiniCartButton: Locator; + readonly mainMenuMyAccountItem: Locator; + readonly mainMenuSearchButton: Locator; + readonly mainMenuLoginItem: Locator; + readonly mainMenuCreateAccountButton: Locator; + readonly mainMenuWishListButton: Locator; + readonly mainMenuMyOrdersButton: Locator; + readonly mainMenuAddressBookButton: Locator; + readonly mainMenuLogoutItem: Locator; + + constructor(page: Page) { + this.page = page; + this.mainMenuElement = page.locator(UIReference.general.headerLocator); + //this.mainMenuAccountButton = this.mainMenuElement.getByRole('button', { name: UIReference.mainMenu.myAccountButtonLabel }); + this.mainMenuAccountButton = page.locator('header #customer-menu'); + // this.mainMenuMiniCartButton = this.mainMenuElement.getByLabel(UIReference.mainMenu.miniCartLabel); + //this.mainMenuMiniCartButton = this.mainMenuElement.getByRole('button', {name: UIReference.mainMenu.miniCartLabel}); + //this.mainMenuMiniCartButton = this.mainMenuElement.locator('#menu-cart-icon'); + this.mainMenuMiniCartButton = page.getByRole('button', { name: /toggle minicart/i }); + this.mainMenuMyAccountItem = this.mainMenuElement.getByTitle(UIReference.mainMenu.myAccountButtonLabel); + this.mainMenuSearchButton = this.mainMenuElement.getByRole('button', {name: UIReference.mainMenu.searchButtonLabel}); + + this.mainMenuLoginItem = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.loginButtonLabel}); + this.mainMenuCreateAccountButton = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.createAccountButtonLabel}); + this.mainMenuWishListButton = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.wishListButtonLabel}); + this.mainMenuMyOrdersButton = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.myOrdersButtonLabel}); + this.mainMenuAddressBookButton = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.addressBookButtonLabel}); + this.mainMenuLogoutItem = this.mainMenuElement.getByTitle(UIReference.mainMenu.myAccountLogoutItem); + //this.mainMenuLogoutItem = this.mainMenuElement.getByRole('link', {name: UIReference.mainMenu.myAccountLogoutItem}); + } + + /** + * Function for the test Navigate_to_category_page + */ + async goToCategoryPage() { + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + //await this.page.getByRole('link', { name: UIReference.categoryPage.categoryPageTitleText, exact: true }).click(); + const menMenu = this.page.getByRole('link', { name: 'Men', exact: true }); + await menMenu.click(); + await this.page.getByRole('link', { name: UIReference.categoryPage.categoryPageTitleText }).click(); + + await this.page.waitForURL(slugs.categoryPage.categorySlug); + await expect( + this.page.getByRole('heading', {name: UIReference.categoryPage.categoryPageTitleText}), + `Heading "${UIReference.categoryPage.categoryPageTitleText}" is visible`).toBeVisible(); + } + + /** + * Function for the test Navigate_to_subcategory_page + */ + async goToSubCategoryPage() { + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + const categoryLink = this.page.getByRole('link', { name: UIReference.mainMenu.categoryItemText, exact: true }); + + await categoryLink.click(); + await this.page.getByRole('link', {name: UIReference.mainMenu.subCategoryItemText}).click(); + await this.page.waitForURL(slugs.categoryPage.subcategorySlug); + + await expect(this.page.getByRole('heading', + { name: outcomeMarker.categoryPage.subCategoryPageTitle }), + `Category page title "${outcomeMarker.categoryPage.subCategoryPageTitle}" is visible`).toBeVisible(); + } + + /** + * Function for the test User_navigates_account_page + */ + async gotoMyAccount(){ + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + await this.mainMenuAccountButton.waitFor(); + await this.mainMenuAccountButton.click(); + await this.mainMenuMyAccountItem.click(); + + await expect(this.page.getByRole('heading', { name: UIReference.accountDashboard.accountDashboardTitleLabel }), 'Account dashboard is visible').toBeVisible(); + } + + /** + * Function for the test User_navigates_to_login + */ + async goToLoginPage() { + const loginHeader = this.page.getByRole('heading', {name: outcomeMarker.login.loginHeaderText, exact:true}); + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + + await this.mainMenuLoginItem.click(); + await this.page.waitForURL(`${slugs.account.loginSlug}**`); + await expect(loginHeader, 'Login header text is visible').toBeVisible(); + } + + /** + * Function for the test User_navigates_to_create_account + */ + async goToCreateAccountPage() { + const createAccountHeader = this.page.getByRole('heading', {name: outcomeMarker.account.createAccountHeaderText, exact:true}); + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + + await this.mainMenuCreateAccountButton.click(); + await this.page.waitForURL(slugs.account.createAccountSlug); + await expect(createAccountHeader, 'Create account header text is visible').toBeVisible(); + } + + async goToAddressBook() { + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + await this.mainMenuAccountButton.waitFor(); + await this.mainMenuAccountButton.click(); + + await this.mainMenuAddressBookButton.click(); + + await this.page.waitForURL(`${slugs.account.addressBookSlug}/**`); + if(this.page.url().includes('new')) { + // no address has been added yet + await expect(this.page.getByRole( + 'heading', {name: UIReference.newAddress.addNewAddressTitle, level: 1, exact:true}), + `Heading "${UIReference.newAddress.addNewAddressTitle}" is visible`).toBeVisible(); + } else { + await expect(this.page.getByRole( + 'heading', {name: UIReference.address.addressBookTitle, level: 1, exact: true}), + `Heading "${UIReference.address.addressBookTitle}" is visible`).toBeVisible(); + } + } + + /** + * Function for the test Navigate_to_orders + */ + async goToOrders() { + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + await this.mainMenuAccountButton.waitFor(); + await this.mainMenuAccountButton.click(); + + await this.mainMenuMyOrdersButton.click(); + await this.page.waitForURL(slugs.account.orderHistorySlug); + await expect(this.page.getByRole( + 'heading', {name: UIReference.orderHistoryPage.orderHistoryTitle, level: 1, exact:true}), + `Heading "${UIReference.orderHistoryPage.orderHistoryTitle}" is visible`).toBeVisible(); + } + + /** + * Function for the test Navigate_to_wishlist + */ + async goToWishList() { + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + await this.mainMenuAccountButton.waitFor(); + await this.mainMenuAccountButton.click(); + + await this.mainMenuWishListButton.click(); + await this.page.waitForURL(slugs.wishList.wishListSlug); + await expect(this.page.getByRole( + 'heading', {name: UIReference.wishListPage.wishListTitle, exact:true}), + `Heading "${UIReference.wishListPage.wishListTitle}" is visible`).toBeVisible(); + } + + /** + * Function for the test Open_the_minicart + */ + async openMiniCartEmpty() { + await this.mainMenuMiniCartButton.waitFor(); + // Trial first, since 'force' skips the actionability check + await this.mainMenuMiniCartButton.click({trial: true}); + // By adding 'force', we can bypass the 'aria-disabled' tag. + await this.mainMenuMiniCartButton.click({force: true}); + + let miniCartDrawer = this.page.locator(UIReference.miniCart.cartDrawerLocator); + await expect(async() => { + await expect(miniCartDrawer.getByText('You have no items in your shopping cart.')).toBeVisible(); + }).toPass(); + } + + /** + * Function for the test Open_the_minicart + */ + async openMiniCart() { + await this.mainMenuMiniCartButton.waitFor(); + // Trial first, since 'force' skips the actionability check + await this.mainMenuMiniCartButton.click({trial: true}); + // By adding 'force', we can bypass the 'aria-disabled' tag. + await this.mainMenuMiniCartButton.click({force: true}); + + //let miniCartDrawer = this.page.locator(UIReference.miniCart.cartDrawerLocator); + const miniCartDrawer = this.page.getByRole('dialog', { name: /my cart/i }); + + await expect(miniCartDrawer).toBeVisible(); + + await expect(async() => { + await expect(miniCartDrawer.getByText('Checkout')).toBeVisible(); + }).toPass(); + } + + /** + * Used for function User_searches_for_product + * @param searchTerm + */ + async searchForProduct(searchTerm :string) { + const searchField = this.page.getByRole('searchbox', { name: UIReference.search.searchBoxPlaceholderText }); + await this.page.goto(requireEnv('PLAYWRIGHT_BASE_URL')); + await this.mainMenuAccountButton.waitFor(); + + await this.mainMenuSearchButton.click(); + await expect(searchField, 'Search field is visible').toBeVisible(); + await searchField.fill(searchTerm); + await expect(this.page.getByText(UIReference.search.searchTermDropdownText, { exact: true }), 'Dropdown with results is visible').toBeVisible(); + await searchField.press('Enter'); + + await this.page.waitForURL(`**/?q=${searchTerm}`); + await expect(this.page.getByRole('heading', + { name: `${UIReference.search.searchResultsTitle} \'${searchTerm}\'` }), + `Title contains search term: "${searchTerm}"`).toBeVisible(); + } + + /** + * Function for the test User_logs_out + */ + async logout(){ + await this.page.goto(slugs.account.accountOverviewSlug); + await this.mainMenuAccountButton.click(); + await this.mainMenuLogoutItem.click(); + + //assertions: notification that user is logged out & logout button no longer visible + await expect(this.page.getByText(outcomeMarker.logout.logoutConfirmationText, { exact: true }), "Message shown that confirms you're logged out").toBeVisible(); + await expect(this.mainMenuLogoutItem, `Log out button is no longer visible`).toBeHidden(); + } +} + +export default MainMenuPage; diff --git a/dev/tests/e2e/tests/poms/frontend/minicart.page.ts b/dev/tests/e2e/tests/poms/frontend/minicart.page.ts new file mode 100644 index 00000000000..6d109237f18 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/minicart.page.ts @@ -0,0 +1,72 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, slugs } from '@config'; + +class MiniCartPage { + readonly page: Page; + readonly toCheckoutButton: Locator; + readonly toCartButton: Locator; + readonly editProductButton: Locator; + readonly productQuantityField: Locator; + readonly updateItemButton: Locator; + readonly priceOnPDP: Locator; + readonly priceInMinicart: Locator; + + constructor(page: Page) { + this.page = page; + this.toCheckoutButton = page.getByRole('button', { name: UIReference.miniCart.checkOutButtonLabel }); + this.toCartButton = page.getByRole('link', { name: UIReference.miniCart.toCartLinkLabel }); + this.editProductButton = page.getByRole('link', { name: UIReference.miniCart.editProductIconLabel }); + this.productQuantityField = page.getByLabel(UIReference.miniCart.productQuantityFieldLabel); + this.updateItemButton = page.getByRole('button', { name: UIReference.cart.updateItemButtonLabel }); + this.priceOnPDP = page.getByLabel(UIReference.general.genericPriceLabel).getByText(UIReference.general.genericPriceSymbol); + this.priceInMinicart = page.getByText(UIReference.general.genericPriceSymbol).first(); + } + + async goToCheckout(){ + await this.toCheckoutButton.click(); + await expect(this.page).toHaveURL(new RegExp(`${slugs.checkout.checkoutSlug}.*`)); + } + + async goToCart(){ + await this.toCartButton.click(); + await expect(this.page).toHaveURL(new RegExp(`${slugs.cart.cartSlug}.*`)); + } + + async removeProductFromMinicart(product: string) { + let productRemovedNotification = outcomeMarker.miniCart.productRemovedConfirmation; + let removeProductMiniCartButton = this.page.getByRole('link', { name: 'Remove'} ); + // ensure button is visible + await removeProductMiniCartButton.waitFor(); + await removeProductMiniCartButton.click(); + await expect(removeProductMiniCartButton, `Button to move product from minicart is no longer visible`).toBeHidden(); + await expect(this.page.getByText(UIReference.miniCart.cartEmptyText), `Minicart shows text "Cart is empty"`).toBeVisible(); + } + + async updateProduct(amount: string){ + let productQuantityChangedNotification = outcomeMarker.miniCart.productQuantityChangedConfirmation; + //await this.editProductButton.click(); + //await expect(this.page).toHaveURL(new RegExp(`${slugs.cart.cartProductChangeSlug}.*`)); + + await this.productQuantityField.first().click(); + await this.productQuantityField.first().fill(amount); + + await this.updateItemButton.click(); + //await expect.soft(this.page.getByText(productQuantityChangedNotification)).toBeVisible(); + + let productQuantityInCart = await this.page.getByLabel(UIReference.cart.cartQuantityLabel).first().inputValue(); + expect(productQuantityInCart).toBe(amount); + } + + async checkPriceWithProductPage() { + const priceOnPage = await this.page.locator(UIReference.productPage.simpleProductPrice).first().innerText(); + const productTitle = await this.page.getByRole('heading', { level : 1}).innerText(); + const productListing = this.page.locator('div').filter({hasText: productTitle}); + const priceInMinicart = await productListing.locator(UIReference.miniCart.minicartPriceFieldClass).first().textContent(); + //expect(priceOnPage).toBe(priceInMinicart); + expect(priceOnPage, `Expect these prices to be the same: priceOnpage: ${priceOnPage} and priceInMinicart: ${priceInMinicart}`).toBe(priceInMinicart); + } +} + +export default MiniCartPage; diff --git a/dev/tests/e2e/tests/poms/frontend/newsletter.page.ts b/dev/tests/e2e/tests/poms/frontend/newsletter.page.ts new file mode 100644 index 00000000000..7fd19c8a1bc --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/newsletter.page.ts @@ -0,0 +1,51 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, inputValues } from '@config'; +import { faker } from '@faker-js/faker' + +class NewsletterSubscriptionPage { + readonly page: Page; + readonly newsletterCheckElement: Locator; + readonly saveSubscriptionsButton: Locator; + + constructor(page: Page) { + this.page = page; + this.newsletterCheckElement = page.getByLabel(UIReference.newsletterSubscriptions.generalSubscriptionCheckLabel); + this.saveSubscriptionsButton = page.getByRole('button', {name:UIReference.newsletterSubscriptions.saveSubscriptionsButton}); + } + + async updateNewsletterSubscription(){ + + let subscriptionUpdatedNotification = outcomeMarker.account.newsletterRemovedNotification; + let subscribed = false; + + if(await this.newsletterCheckElement.isChecked()) { + // user is already subscribed, test runs unsubscribe + await this.newsletterCheckElement.uncheck(); + await this.saveSubscriptionsButton.click(); + + } else { + // user is not yet subscribed, test runs subscribe + subscriptionUpdatedNotification = outcomeMarker.account.newsletterSavedNotification; + + await this.newsletterCheckElement.check(); + await this.saveSubscriptionsButton.click(); + + subscribed = true; + } + + await expect(this.page.getByText(subscriptionUpdatedNotification)).toBeVisible(); + return subscribed; + } + + async footerSubscribeToNewsletter() { + const form = this.page.locator('#newsletter-validate-detail'); + const emailField = form.locator('input[name="email"]'); + await expect(emailField).toBeVisible(); + await emailField.fill(faker.internet.email()); + await this.page.getByRole('button', {name: UIReference.footerPage.newsletterSubscribeButtonLabel}).click(); + } +} + +export default NewsletterSubscriptionPage; diff --git a/dev/tests/e2e/tests/poms/frontend/orderhistory.page.ts b/dev/tests/e2e/tests/poms/frontend/orderhistory.page.ts new file mode 100644 index 00000000000..1be0ee6973e --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/orderhistory.page.ts @@ -0,0 +1,23 @@ +// @ts-check + +import { expect, type Page } from '@playwright/test'; +import { slugs } from '@config'; + +class OrderHistoryPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async open() { + await this.page.goto(slugs.account.orderHistorySlug); + await this.page.waitForLoadState(); + } + + async verifyOrderPresent(orderNumber: string) { + await expect(this.page.getByText(orderNumber)).toBeVisible(); + } +} + +export default OrderHistoryPage; diff --git a/dev/tests/e2e/tests/poms/frontend/product.page.ts b/dev/tests/e2e/tests/poms/frontend/product.page.ts new file mode 100644 index 00000000000..93548dacd05 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/product.page.ts @@ -0,0 +1,197 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, slugs } from '@config'; + +class ProductPage { + readonly page: Page; + simpleProductTitle: Locator | undefined; + configurableProductTitle: Locator | undefined; + addToCartButton: Locator; + addToCompareButton: Locator; + addToWishlistButton: Locator; + + constructor(page: Page) { + this.page = page; + this.addToCartButton = page.getByRole('button', { name: UIReference.productPage.addToCartButtonLocator, exact:true }); + this.addToCompareButton = page.getByLabel(UIReference.productPage.addToCompareButtonLabel, { exact: true }); + this.addToWishlistButton = page.getByLabel(UIReference.productPage.addToWishlistButtonLabel, { exact: true }); + } + + // ============================================== + // Productpage-related methods + // ============================================== + + async addProductToCompare(product:string, url: string){ + let productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} product`; + const successMessage = this.page.locator(UIReference.general.successMessageLocator); + + await this.page.goto(url); + + await this.addToCompareButton.click(); + await successMessage.waitFor(); + await expect(this.page.getByText(productAddedNotification)).toBeVisible(); + + await this.page.goto(slugs.productPage.productComparisonSlug); + + // Assertion: a cell with the product name inside a cell with the product name should be visible + await expect(this.page.getByRole('cell', {name: product}).getByText(product, {exact: true})).toBeVisible(); + } + + async addProductToWishlist(product:string, url: string){ + /** + * Note that the test Add_product_to_wishlist is currently set to fixme + */ + let addedToWishlistNotification = `${product} ${outcomeMarker.wishListPage.wishListAddedNotification}`; + await this.page.goto(url); + await this.addToWishlistButton.waitFor(); + this.addToWishlistButton.click(); + + await expect(async () => { + await this.page.waitForSelector(UIReference.general.messageLocator, { state: 'visible' }); + }).toPass(); + + await expect(this.page.getByText(addedToWishlistNotification), "Notification that product has been added is visible").toBeVisible(); + + await expect(async () => { + await expect(this.page.getByText(addedToWishlistNotification)).toBeVisible(); + }).toPass(); + + let productNameInWishlist = this.page.locator(UIReference.wishListPage.wishListItemGridLabel).getByText(UIReference.productPage.simpleProductTitle, {exact: true}); + + await expect(this.page).toHaveURL(new RegExp(slugs.wishList.wishListRegex)); + await expect(this.page.getByText(addedToWishlistNotification)).toBeVisible(); + await expect(productNameInWishlist).toContainText(product); + } + + async leaveProductReview(product:string, url: string){ + + await this.page.goto(url); + + //TODO: Uncomment this and fix test once website is fixed + /* + await page.locator('#Rating_5_label path').click(); + await page.getByPlaceholder('Nickname*').click(); + await page.getByPlaceholder('Nickname*').fill('John'); + await page.getByPlaceholder('Nickname*').press('Tab'); + await page.getByPlaceholder('Summary*').click(); + await page.getByPlaceholder('Summary*').fill('A short paragraph'); + await page.getByPlaceholder('Review*').click(); + await page.getByPlaceholder('Review*').fill('Review message!'); + await page.getByRole('button', { name: 'Submit Review' }).click(); + await page.getByRole('img', { name: 'loader' }).click(); + */ + } + + async openLightboxAndScrollThrough(url: string){ + + await this.page.goto(url); + let fullScreenOpener = this.page.getByLabel(UIReference.productPage.fullScreenOpenLabel); + let fullScreenCloser = this.page.getByLabel(UIReference.productPage.fullScreenCloseLabel); + let thumbnails = this.page.getByRole('button', {name: UIReference.productPage.thumbnailImageLabel}); + + await fullScreenOpener.click(); + await expect(fullScreenCloser).toBeVisible(); + + for (const img of await thumbnails.all()) { + await img.click(); + // wait for transition animation + await this.page.waitForTimeout(500); + await expect(img, `CSS class 'border-primary' appended to button`).toHaveClass(new RegExp(outcomeMarker.productPage.borderClassRegex)); + } + + await fullScreenCloser.click(); + await expect(fullScreenCloser).toBeHidden(); + + } + + async changeReviewCountAndVerify(url: string) { + + await this.page.goto(url); + + // Get the default review count from URL or UI + const initialUrl = this.page.url(); + + // Find and click the review count selector + const reviewCountSelector = this.page.getByLabel(UIReference.productPage.reviewCountLabel); + await expect(reviewCountSelector).toBeVisible(); + + // Select 20 reviews per page + await reviewCountSelector.selectOption('20'); + await this.page.waitForURL(/.*limit=20.*/); + + // Verify URL contains the new limit + const urlAfterFirstChange = this.page.url(); + expect(urlAfterFirstChange, 'URL should contain limit=20 parameter').toContain('limit=20'); + expect(urlAfterFirstChange, 'URL should have changed after selecting 20 items per page').not.toEqual(initialUrl); + + // Select 50 reviews per page + await reviewCountSelector.selectOption('50'); + await this.page.waitForURL(/.*limit=50.*/); + + // Verify URL contains the new limit + const urlAfterSecondChange = this.page.url(); + expect(urlAfterSecondChange, 'URL should contain limit=50 parameter').toContain('limit=50'); + expect(urlAfterSecondChange, 'URL should have changed after selecting 50 items per page').not.toEqual(urlAfterFirstChange); + } + + // ============================================== + // Cart-related methods + // ============================================== + + async addSimpleProductToCart(product: string, url: string, quantity?: string) { + + await this.page.goto(url); + + const productInfo = this.page.getByRole('region', { name: 'Product Info' }); + this.simpleProductTitle = productInfo.getByText(product, {exact:true}); + await expect(this.simpleProductTitle).toBeVisible(); + + if(quantity){ + // set quantity + await this.page.getByRole('spinbutton', {name: UIReference.productPage.quantityFieldLabel}).fill('2'); + } + + // assert visibility to ensure we can click the add to cart button. + await expect(this.addToCartButton).toBeVisible(); + await this.addToCartButton.click(); + const successMessage = this.page.locator(UIReference.general.successMessageLocator); + + await expect( + successMessage, + 'Product has been added to cart' + ).toContainText( + `${outcomeMarker.productPage.simpleProductAddedNotification} ${product}` + ); + } + + async addConfigurableProductToCart(product: string, url:string, quantity?:string) { + + await this.page.goto(url); + + this.configurableProductTitle = this.page.getByLabel('Product Info').getByText(product, {exact:true}); + let productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`; + const productOptions = this.page.locator(UIReference.productPage.configurableProductOptionForm); + + // wait for the color and size selectors are actually visible + await productOptions.getByRole('radiogroup').first().waitFor(); + await productOptions.getByRole('radiogroup').last().waitFor(); + + // loop through each radiogroup (product option) within the form + for (const option of await productOptions.getByRole('radiogroup').all()) { + await option.locator(UIReference.productPage.configurableProductOptionValue).first().check(); + } + + if(quantity){ + // set quantity + await this.page.getByLabel(UIReference.productPage.quantityFieldLabel).fill('2'); + } + + await this.addToCartButton.click(); + let successMessage = this.page.locator(UIReference.general.successMessageLocator); + await successMessage.waitFor(); + await expect(this.page.getByText(productAddedNotification)).toBeVisible(); + } +} + +export default ProductPage; diff --git a/dev/tests/e2e/tests/poms/frontend/register.page.ts b/dev/tests/e2e/tests/poms/frontend/register.page.ts new file mode 100644 index 00000000000..03801d56bdc --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/register.page.ts @@ -0,0 +1,84 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker, slugs} from '@config'; +import MainMenuPage from "@poms/frontend/mainmenu.page"; + +class RegisterPage { + readonly page: Page; + readonly accountCreationFirstNameField: Locator; + readonly accountCreationLastNameField: Locator; + readonly accountCreationEmailField: Locator; + readonly accountCreationPasswordField: Locator; + readonly accountCreationPasswordRepeatField: Locator; + readonly accountCreationConfirmButton: Locator; + + constructor(page: Page){ + this.page = page; + const form = page.locator('#form-validate'); + this.accountCreationFirstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel); + //this.accountCreationFirstNameField = form.locator('input[name="firstname"]'); + this.accountCreationLastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel); + //this.accountCreationLastNameField = form.locator('input[name="lastname"]'); + this.accountCreationEmailField = page.getByRole('textbox', {name: UIReference.credentials.emailFieldLabel, exact: true}); + //this.accountCreationEmailField = form.locator('input[name="email"]'); + this.accountCreationPasswordField = page.getByRole('textbox', {name: UIReference.credentials.passwordFieldLabel, exact:true}); + //this.accountCreationPasswordField = form.locator('input[name="password"]'); + this.accountCreationPasswordRepeatField = page.getByRole('textbox', {name: UIReference.credentials.passwordConfirmFieldLabel}); + //this.accountCreationPasswordRepeatField = form.locator('input[name="password_confirmation"]'); + this.accountCreationConfirmButton = page.getByRole('button', {name: UIReference.accountCreation.createAccountButtonLabel}); + //this.accountCreationConfirmButton = form.locator('button[type="submit"]'); + } + + + async createNewAccount(firstName: string, lastName: string, email: string, password: string, isSetup: boolean = false){ + //let accountInformationField = this.page.locator(UIReference.accountDashboard.accountInformationFieldLocator).first(); + await this.page.goto(slugs.account.createAccountSlug); + + await expect(async () => { + await expect(this.page.getByRole('heading', + { name: UIReference.accountCreation.createAccountTitleText }), + `Heading "${UIReference.accountCreation.createAccountTitleText}" is visible`).toBeVisible(); + }).toPass(); + // await expect(async () => { + // await expect( + // this.page.getByRole('heading', { level: 1 }) + // ).toHaveText('My Account'); + // }).toPass(); + + //const mainMenu = new MainMenuPage(this.page); + //await mainMenu.logout() + await this.page.goto(slugs.account.createAccountSlug); + await expect(async () => { + await expect( + this.page.getByRole('heading', { level: 1 }) + ).toHaveText('Create New Customer Account'); + }).toPass(); + + await this.accountCreationFirstNameField.fill(firstName); + await this.accountCreationLastNameField.fill(lastName); + await this.accountCreationEmailField.fill(email); + await this.accountCreationPasswordField.fill(password); + await this.accountCreationPasswordRepeatField.fill(password); + await this.accountCreationConfirmButton.click(); + + if(!isSetup) { + await this.page.waitForLoadState(); + // Assertions: Account created notification, navigated to account page, email visible on page + await expect(this.page.getByText(outcomeMarker.account.accountCreatedNotificationText), 'Account creation notification should be visible').toBeVisible(); + + await this.page.goto(slugs.account.accountOverviewSlug); + await expect(this.page.getByRole('heading', + {name: UIReference.accountDashboard.accountDashboardTitleLabel, level:2}), + `Heading "${UIReference.accountDashboard.accountDashboardTitleLabel}" is visible`).toBeVisible(); + const accountInfoBlock = this.page.locator('.column.main'); + const contactInfoBox = accountInfoBlock.locator('h3:has-text("Contact Information")').locator('..'); + const contactInfoContent = contactInfoBox.locator('p'); + + await expect(contactInfoContent, `Account information should contain email: ${email}`) + .toContainText(email); + } + } +} + +export default RegisterPage; diff --git a/dev/tests/e2e/tests/poms/frontend/search.page.ts b/dev/tests/e2e/tests/poms/frontend/search.page.ts new file mode 100644 index 00000000000..44ec11849fe --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/search.page.ts @@ -0,0 +1,33 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference } from '@config'; + +class SearchPage { + readonly page: Page; + readonly searchToggle: Locator; + readonly searchInput: Locator; + readonly suggestionBox: Locator; + + constructor(page: Page) { + this.page = page; + this.searchToggle = page.locator(UIReference.search.searchToggleLocator); + this.searchInput = page.locator(UIReference.search.searchInputLocator); + this.suggestionBox = page.locator(UIReference.search.suggestionBoxLocator); + } + + async openSearch() { + await this.searchToggle.waitFor({ state: 'visible' }); + await this.searchToggle.click(); + await expect(this.searchInput).toBeVisible(); + } + + async search(query: string) { + await this.openSearch(); + await this.searchInput.fill(query); + await this.searchInput.press('Enter'); + await this.page.waitForLoadState('networkidle'); + } +} + +export default SearchPage; diff --git a/dev/tests/e2e/tests/poms/frontend/shoppingcart.page.ts b/dev/tests/e2e/tests/poms/frontend/shoppingcart.page.ts new file mode 100644 index 00000000000..190d236aba7 --- /dev/null +++ b/dev/tests/e2e/tests/poms/frontend/shoppingcart.page.ts @@ -0,0 +1,156 @@ +// @ts-check + +import { expect, type Locator, type Page } from '@playwright/test'; +import { UIReference, outcomeMarker } from '@config'; + +class CartPage { + readonly page: Page; + readonly showDiscountButton: Locator; + productQuantityInCheckout: string | undefined; + productPriceInCheckout: string | undefined; + + constructor(page: Page) { + this.page = page; + // this.showDiscountButton = this.page.getByRole('button', { name: UIReference.cart.showDiscountFormButtonLabel }); + this.showDiscountButton = this.page.locator('summary').filter({hasText: UIReference.cart.showDiscountFormButtonLabel}); + } + + async changeProductQuantity(amount: string){ + const productRow = this.page.getByRole('listitem').filter({hasText: UIReference.productPage.simpleProductTitle}); + let currentQuantity = await productRow.getByRole('spinbutton', {name: UIReference.cart.cartQuantityLabel}).inputValue(); + + if(currentQuantity == amount){ + amount = '3'; + } + + let subTotalBeforeUpdate = await productRow.getByText(UIReference.general.genericPriceSymbol).last().innerText(); + await productRow.getByLabel(UIReference.cart.cartQuantityLabel).fill(amount); + await this.page.getByRole('button', { name: UIReference.cart.updateShoppingCartButtonLabel }).click(); + + await expect(async () => { + let subTotalAfterUpdate = await productRow.getByText(UIReference.general.genericPriceSymbol).last().innerText(); + await expect(subTotalBeforeUpdate, `Subtotal should change`).not.toEqual(subTotalAfterUpdate); + }).toPass(); + + let updatedQuantity = await productRow.getByLabel(UIReference.cart.cartQuantityLabel).inputValue(); + expect(updatedQuantity, `updated quantity (${updatedQuantity}) should equal amount we've requested (${amount})`).toEqual(amount); + } + + // ============================================== + // Product-related methods + // ============================================== + + async removeProduct(productTitle: string){ + let removeButton = this.page.getByLabel(`${UIReference.general.removeLabel} ${productTitle}`); + await removeButton.click(); + await this.page.waitForLoadState(); + await expect(removeButton,`Button to remove specified product is not visible in the cart`).toBeHidden(); + + // Expect product to no longer be visible in the cart + await expect (this.page.getByRole('cell', { name: productTitle }), `Product is not visible in cart`).toBeHidden(); + } + + // ============================================== + // Discount-related methods + // ============================================== + async applyDiscountCode(code: string){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountButton.click(); + } + + let applyDiscoundButton = this.page.getByRole('button', {name: UIReference.cart.applyDiscountButtonLabel, exact:true}); + let discountField = this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel); + await discountField.fill(code); + await applyDiscoundButton.click(); + await this.page.waitForLoadState(); + + const notificationBanner = this.page.locator(UIReference.general.successMessageLocator) + .filter({hasText: outcomeMarker.cart.discountAppliedNotification}); + await notificationBanner.waitFor(); + + await expect.soft(this.page.getByText(`${outcomeMarker.cart.discountAppliedNotification} "${code}"`),`Notification that discount code ${code} has been applied`).toBeVisible(); + await expect(this.page.getByText(outcomeMarker.cart.priceReducedSymbols),`'- $' should be visible on the page`).toBeVisible(); + //Close message to prevent difficulties with other tests. + await this.page.getByLabel(UIReference.general.closeMessageLabel).click(); + } + + async removeDiscountCode(){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountButton.click(); + } + + let cancelCouponButton = this.page.getByRole('button', {name: UIReference.cart.cancelCouponButtonLabel}); + await cancelCouponButton.click(); + await this.page.waitForLoadState(); + + await expect.soft(this.page.getByText(outcomeMarker.cart.discountRemovedNotification),`Notification should be visible`).toBeVisible(); + await expect(this.page.getByText(outcomeMarker.cart.priceReducedSymbols),`'- $' should not be on the page`).toBeHidden(); + } + + async enterWrongCouponCode(code: string){ + if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ + // discount field is not open. + await this.showDiscountButton.click(); + } + + let applyDiscoundButton = this.page.getByRole('button', {name: UIReference.cart.applyDiscountButtonLabel, exact:true}); + let discountField = this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel); + await discountField.fill(code); + await applyDiscoundButton.click(); + await this.page.waitForLoadState(); + + let incorrectNotification = `${outcomeMarker.cart.incorrectCouponCodeNotificationOne} "${code}" ${outcomeMarker.cart.incorrectCouponCodeNotificationTwo}`; + + //Assertions: notification that code was incorrect & discount code field is still editable + await expect.soft(this.page.getByText(incorrectNotification), `Code should not work`).toBeVisible(); + await expect(discountField).toBeEditable(); + } + + + // ============================================== + // Additional methods + // ============================================== + + async getCheckoutValues(productName:string, pricePDP:string, amountPDP:string){ + const checkoutCartDetails = this.page.locator(UIReference.checkout.checkoutCartDetailsLocator); + const openCartDetailsButton = this.page.locator(UIReference.checkout.openCartDetailsButtonLocator); + + if(await checkoutCartDetails.isHidden()) { + await openCartDetailsButton.click(); + } + + // // Open minicart based on amount of products in cart + // let cartItemAmount = await this.page.locator(UIReference.miniCart.minicartAmountBubbleLocator).count(); + // if(cartItemAmount == 1) { + // await this.page.getByLabel(`${UIReference.checkout.openCartButtonLabel} ${cartItemAmount} ${UIReference.checkout.openCartButtonLabelCont}`).click(); + // } else { + // await this.page.getByLabel(`${UIReference.checkout.openCartButtonLabel} ${cartItemAmount} ${UIReference.checkout.openCartButtonLabelContMultiple}`).click(); + // } + + // Get values from checkout page + let productInCheckout = this.page.locator(UIReference.checkout.cartDetailsLocator).filter({ hasText: productName }).nth(1); + this.productPriceInCheckout = await productInCheckout.getByText(UIReference.general.genericPriceSymbol).last().innerText(); + this.productPriceInCheckout = this.productPriceInCheckout.trim(); + // let productImage = this.page.locator(UIReference.checkout.cartDetailsLocator) + // .filter({ has: this.page.getByRole('img', { name: productName })}); + // this.productQuantityInCheckout = await productImage.locator('> span').innerText(); + this.productQuantityInCheckout = await productInCheckout.locator('.product-price').getByText('x').innerText(); + this.productQuantityInCheckout = this.productQuantityInCheckout.substring(0,1); + return [this.productPriceInCheckout, this.productQuantityInCheckout]; + } + + async calculateProductPricesAndCompare(pricePDP: string, amountPDP:string, priceCheckout:string, amountCheckout:string){ + // perform magic to calculate price * amount and mold it into the correct form again + pricePDP = pricePDP.replace(UIReference.general.genericPriceSymbol,''); + let pricePDPInt = Number(pricePDP); + let quantityPDPInt = parseInt(amountPDP); + let calculatedPricePDP = `${UIReference.general.genericPriceSymbol}` + (pricePDPInt * quantityPDPInt).toFixed(2); + + expect(amountPDP,`Amount on PDP (${amountPDP}) equals amount in checkout (${amountCheckout})`).toEqual(amountCheckout); + expect(calculatedPricePDP, `Price * qty on PDP (${calculatedPricePDP}) equals price * qty in checkout (${priceCheckout})`).toEqual(priceCheckout); + } +} + +export default CartPage; diff --git a/dev/tests/e2e/tests/product.spec.ts b/dev/tests/e2e/tests/product.spec.ts new file mode 100644 index 00000000000..0318a1df09d --- /dev/null +++ b/dev/tests/e2e/tests/product.spec.ts @@ -0,0 +1,51 @@ +// @ts-check + +import { test } from '@playwright/test'; +import { UIReference ,slugs } from '@config'; + +import ProductPage from '@poms/frontend/product.page'; +import LoginPage from '@poms/frontend/login.page'; +import { requireEnv } from '@utils/env.utils'; + +test.describe('Product page tests',{ tag: '@product',}, () => { + test('Add_product_to_compare',{ tag: '@cold'}, async ({page}) => { + const productPage = new ProductPage(page); + await productPage.addProductToCompare(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + }); + + test.fixme('Add_product_to_wishlist',{ tag: '@cold'}, async ({page, browserName}) => { + /** + * This test is currently (October 2025) set to be fixed, since it causes regular timeouts. + * Various fixes have been tried, unsuccessfully. + */ + await test.step('Log in with account', async () =>{ + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + const loginPage = new LoginPage(page); + await loginPage.login(emailInputValue, passwordInputValue); + }); + + await test.step('Add product to wishlist', async () =>{ + const productPage = new ProductPage(page); + await productPage.addProductToWishlist(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + }); + }); + + + test.fixme('Leave a product review (Test currently fails due to error on website)',{ tag: '@cold'}, async ({}) => { + // const productPage = new ProductPage(page); + // await productPage.leaveProductReview(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + }); + + test('Open_pictures_in_lightbox_and_scroll', async ({page}) => { + const productPage = new ProductPage(page); + await productPage.openLightboxAndScrollThrough(slugs.productPage.configurableProductSlug); + }); + + test('Change_number_of_reviews_shown_on_product_page', async ({page}) => { + const productPage = new ProductPage(page); + await productPage.changeReviewCountAndVerify(slugs.productPage.simpleProductSlug); + }); +}); diff --git a/dev/tests/e2e/tests/search.spec.ts b/dev/tests/e2e/tests/search.spec.ts new file mode 100644 index 00000000000..e1a1c5e5bee --- /dev/null +++ b/dev/tests/e2e/tests/search.spec.ts @@ -0,0 +1,22 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { UIReference, outcomeMarker, inputValues, slugs } from '@config'; + +import SearchPage from '@poms/frontend/search.page'; + +test.describe('Search functionality', () => { + test('User_can_find_a_specific_product_and_navigate_to_its_page', async ({ page }) => { + await page.goto(''); + const searchPage = new SearchPage(page); + await searchPage.search(inputValues.search.querySpecificProduct); + await expect(page).toHaveURL(slugs.productPage.searchProductSlug); + }); + + test('No_results_message_is_shown_for_unknown_query', async ({ page }) => { + await page.goto(''); + const searchPage = new SearchPage(page); + await searchPage.search(inputValues.search.queryNoResults); + await expect(page.getByText(outcomeMarker.search.noResultsMessage)).toBeVisible(); + }); +}); diff --git a/dev/tests/e2e/tests/setup.spec.ts b/dev/tests/e2e/tests/setup.spec.ts new file mode 100644 index 00000000000..0256ce18acd --- /dev/null +++ b/dev/tests/e2e/tests/setup.spec.ts @@ -0,0 +1,116 @@ +// @ts-check + +import { test } from '@playwright/test'; +import { faker } from '@faker-js/faker'; +import { inputValues } from '@config'; +import { requireEnv } from '@utils/env.utils'; +import { createLogger } from '@utils/logger'; + +import AdminLogin from '@poms/adminhtml/login.page'; +import AdminMarketing from '@poms/adminhtml/marketing.page'; +import AdminCustomers from '@poms/adminhtml/customers.page'; + +import RegisterPage from '@poms/frontend/register.page'; + +const logger = createLogger('Setup'); + +const magentoAdminUsername = requireEnv('MAGENTO_ADMIN_USERNAME'); +const magentoAdminPassword = requireEnv('MAGENTO_ADMIN_PASSWORD'); + +test.beforeEach(async ({ page }, testInfo) => { + const adminLoginPage = new AdminLogin(page); + await adminLoginPage.login(magentoAdminUsername, magentoAdminPassword); +}); + +test.describe('Setting up the testing environment', () => { + // Set tests to serial mode to ensure the order is followed. + test.describe.configure({mode:'serial'}); + + /** + * @feature Magento Admin Configuration (disable login CAPTCHA) + * @scenario Disable login CAPTCHA in admin settings via Chromium browser + * @given the test is running in a Chromium-based browser + * @when the admin logs in to the Magento dashboard + * @and the admin navigates to the security configuration section + * @and the "Enable CAPTCHA on Admin Login" setting is updated to "No" + * @then the configuration is saved successfully + * @but if the browser is not Chromium + * @then the test is skipped with an appropriate message + */ + test('Disable_login_captcha', { tag: '@setup' }, async ({ page, browserName }, testInfo) => { + test.skip(browserName !== 'chromium', `Disabling login captcha through Chromium. This is ${browserName}, therefore test is skipped.`); + + const adminLoginPage = new AdminLogin(page); + await adminLoginPage.disableLoginCaptcha(); + }); + + /** + * @feature Magento Admin Configuration (Enable multiple admin logins) + * @scenario Enable multiple admin logins only in Chromium browser + * @given the + * @scenario Enable multiple admin logins only in Chromium browser + * @given the test is running in a Chromium-based browser + * @when the admin logs in to the Magento dashboard + * @and the admin navigates to the configuration page + * @and the "Allow Multiple Admin Account Login" setting is updated to "Yes" + * @then the configuration is saved successfully + * @but if the browser is not Chromium + * @then the test is skipped with an appropriate message + */ + test('Enable_multiple_admin_logins', { tag: '@setup' }, async ({ page, browserName }, testInfo) => { + test.skip(browserName !== 'chromium', `Disabling login captcha through Chromium. This is ${browserName}, therefore test is skipped.`); + + const adminLoginPage = new AdminLogin(page); + await adminLoginPage.enableMultipleAdminLogins(); + }); + + /** + * @feature Cart Price Rules Configuration + * @scenario Set up a coupon code for the current browser environment + * @given a valid coupon code environment variable exists for the current browser engine + * @when the admin navigates to the Cart Price Rules section + * @and the admin creates a new cart price rule with the specified coupon code + * @then the coupon code is successfully saved and available for use + */ + test('Set_up_coupon_codes', { tag: '@setup'}, async ({page, browserName}, testInfo) => { + const adminMarketingPage = new AdminMarketing(page); + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const couponCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); + + const addCouponCodeResult = await adminMarketingPage.addCartPriceRule(couponCode); + testInfo.annotations.push({type: 'notice', description: addCouponCodeResult}); + }); + + /** + * @feature Customer Account Setup + * @scenario Create a test customer account for the current browser environment + * @given valid environment variables for email and password exist for the current browser engine + * @when the user navigates to the registration page + * @and submits the registration form with first name, last name, email, and password + * @then a new customer account is successfully created for testing purposes + */ + test('Create_test_accounts', { tag: '@setup'}, async ({page, browserName}, testInfo) => { + const adminCustomersPage = new AdminCustomers(page); + const registerPage = new RegisterPage(page); + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const accountEmail = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const accountPassword = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + await test.step(`Check if ${accountEmail} is already registered`, async () => { + const customerLookUp = await adminCustomersPage.checkIfCustomerExists(accountEmail); + if(customerLookUp){ + testInfo.skip(true, `${accountEmail} was found in user table, this step is skipped. If you think this is incorrect, consider removing user from the table and try running the setup again.`); + } + }); + + await test.step('Create new customer', async () => { + await registerPage.createNewAccount( + inputValues.accountCreation.firstNameValue, + inputValues.accountCreation.lastNameValue, + accountEmail, + accountPassword, + true + ); + }); + }); +}); diff --git a/dev/tests/e2e/tests/shoppingcart.spec.ts b/dev/tests/e2e/tests/shoppingcart.spec.ts new file mode 100644 index 00000000000..a9e59cff16e --- /dev/null +++ b/dev/tests/e2e/tests/shoppingcart.spec.ts @@ -0,0 +1,118 @@ +// @ts-check + +import { test, expect } from '@playwright/test'; +import { UIReference, slugs, outcomeMarker } from '@config'; + +import CartPage from '@poms/frontend/shoppingcart.page'; +import LoginPage from '@poms/frontend/login.page'; +import ProductPage from '@poms/frontend/product.page'; +import { requireEnv } from '@utils/env.utils'; +import NotificationValidatorUtils from '@utils/notificationValidator.utils'; + +test.describe('Cart functionalities (guest)', () => { + /** + * @feature BeforeEach runs before each test in this group. + * @scenario Add a product to the cart and confirm it's there. + * @given I am on any page + * @when I navigate to a (simple) product page + * @and I add it to my cart + * @then I should see a notification + * @when I click the cart in the main menu + * @then the minicart should become visible + * @and I should see the product in the minicart + */ + test.beforeEach(async ({ page }, testInfo) => { + const productPage = new ProductPage(page); + await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productPage.simpleProductSlug); + + const productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} ${UIReference.productPage.simpleProductTitle}`; + const notificationValidator = new NotificationValidatorUtils(page, testInfo); + await notificationValidator.validate('beforeEach add product to cart'); + + // await mainMenu.openMiniCart(); + // await expect(page.getByText(outcomeMarker.miniCart.simpleProductInCartTitle)).toBeVisible(); + await page.goto(slugs.cart.cartSlug); + }); + + /** + * @feature Product can be added to cart + * @scenario User adds a product to their cart + * @given I have added a product to my cart + * @and I am on the cart page + * @then I should see the name of the product in my cart + */ + test('Add_product_to_cart',{ tag: ['@cart', '@cold'],}, async ({page}) => { + await expect(page.getByRole('heading').getByRole('link', {name: UIReference.productPage.simpleProductTitle}), `Product is visible in cart`).toBeVisible(); + }); + + /** + * @feature Product permanence after login + * @scenario A product added to the cart should still be there after user has logged in + * @given I have a product in my cart + * @when I log in + * @then I should still have that product in my cart + */ + test('Product_remains_in_cart_after_login',{ tag: ['@cart', '@account', '@hot']}, async ({page, browserName}) => { + await test.step('Add another product to cart', async () =>{ + const productpage = new ProductPage(page); + await page.goto(slugs.productPage.secondSimpleProductSlug); + await productpage.addSimpleProductToCart(UIReference.productPage.secondSimpleProducTitle, slugs.productPage.secondSimpleProductSlug); + }); + + await test.step('Log in with account', async () =>{ + const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; + const loginPage = new LoginPage(page); + const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); + const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); + + await loginPage.login(emailInputValue, passwordInputValue); + }); + + await page.goto(slugs.cart.cartSlug); + await expect(page.getByRole('heading').getByRole('link', { name: UIReference.productPage.simpleProductTitle }),`${UIReference.productPage.simpleProductTitle} should still be in cart`).toBeVisible(); + await expect(page.getByRole('heading').getByRole('link', { name: UIReference.productPage.secondSimpleProducTitle }),`${UIReference.productPage.secondSimpleProducTitle} should still be in cart`).toBeVisible(); + }); + + /** + * @feature Remove product from cart + * @scenario User has added a product and wants to remove it from the cart page + * @given I have added a product to my cart + * @and I am on the cart page + * @when I click the delete button + * @then I should see a notification that the product has been removed from my cart + * @and I should no longer see the product in my cart + */ + test('Remove_product_from_cart',{ tag: ['@cart','@cold'],}, async ({page}) => { + const cart = new CartPage(page); + await cart.removeProduct(UIReference.productPage.simpleProductTitle); + }); + + /** + * @feature Change quantity of products in cart + * @scenario User has added a product and changes the quantity + * @given I have a product in my cart + * @and I am on the cart page + * @when I change the quantity of the product + * @and I click the update button + * @then the quantity field should have the new amount + * @and the subtotal/grand total should update + */ + test('Change_product_quantity_in_cart',{ tag: ['@cart', '@cold'],}, async ({page}) => { + const cart = new CartPage(page); + await cart.changeProductQuantity('2'); + }); + + /** + * @feature Incorrect discount code check + * @scenario The user provides an incorrect discount code, the system should reflect that + * @given I have a product in my cart + * @and I am on the cart page + * @when I enter a wrong discount code + * @then I should get a notification that the code did not work. + */ + + test('Invalid_coupon_code_is_rejected',{ tag: ['@cart', '@coupon-code', '@cold'] }, async ({page}) => { + const cart = new CartPage(page); + await cart.enterWrongCouponCode("Incorrect Coupon Code"); + }); +}) diff --git a/dev/tests/e2e/tests/types/magewire.d.ts b/dev/tests/e2e/tests/types/magewire.d.ts new file mode 100644 index 00000000000..346a10ac35f --- /dev/null +++ b/dev/tests/e2e/tests/types/magewire.d.ts @@ -0,0 +1,8 @@ +// @ts-check + +interface Window { + magewire?: { + processing: boolean; + [key: string]: any; + }; +} diff --git a/dev/tests/e2e/tests/utils/apiClient.utils.ts b/dev/tests/e2e/tests/utils/apiClient.utils.ts new file mode 100644 index 00000000000..43d9b9b52d0 --- /dev/null +++ b/dev/tests/e2e/tests/utils/apiClient.utils.ts @@ -0,0 +1,157 @@ +// @ts-check + +import { request, expect, APIRequestContext, APIResponse } from '@playwright/test'; +import { requireEnv } from '@utils/env.utils'; + +class ApiClient { + private context!: APIRequestContext; + private token: string | undefined; + private tokenExpiry: number | undefined; + + constructor() {} + + /** + * Initializes the ApiClient by ensuring a valid token and setting up the request context. + * @returns {Promise} A Promise that resolves to an instance of ApiClient. + */ + async create(): Promise { + await this.ensureToken(); + + this.context = await request.newContext({ + baseURL: requireEnv('PLAYWRIGHT_BASE_URL'), + extraHTTPHeaders: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.token}`, + }, + }); + + return this; + } + + /** + * Ensures the API token is valid, refreshing it if expired or absent. + * @private + * @returns {Promise} + */ + private async ensureToken(): Promise { + if (!this.token || this.isTokenExpired()) { + this.token = await this.refreshIntegrationToken(); + } + } + + /** + * Fetches a new API token from the server and sets the expiry time. + * @private + * @returns {Promise} A Promise that resolves to the token string. + * @throws {Error} If token retrieval fails. + */ + private async refreshIntegrationToken(): Promise { + const tempContext = await request.newContext({ + baseURL: requireEnv('PLAYWRIGHT_BASE_URL'), + extraHTTPHeaders: { + 'Content-Type': 'application/json', + }, + }); + + const response = await tempContext.post('/rest/V1/integration/admin/token', { + data: { + username: requireEnv('MAGENTO_API_USERNAME'), + password: requireEnv('MAGENTO_API_PASSWORD'), + }, + }); + + if (!response.ok()) { + const errorBody = await response.text(); + await tempContext.dispose(); + throw new Error(`Failed to obtain integration token: ${response.status()} ${errorBody}`); + } + + const token = await response.json(); + const expiresHeader = response.headers()['expires']; + if (expiresHeader) { + this.tokenExpiry = new Date(expiresHeader).getTime(); + } else { + this.tokenExpiry = Date.now() + (3600 * 1000); + } + + await tempContext.dispose(); + return token; + } + + /** + * Determines if the current token is expired. + * @private + * @returns {boolean} True if the token is expired, otherwise false. + */ + private isTokenExpired(): boolean { + return !this.tokenExpiry || Date.now() >= this.tokenExpiry; + } + + /** + * Performs a GET request to the specified URL. + * @param {string} url The endpoint URL to send the request to. + * @returns {Promise} A Promise that resolves to the response JSON. + */ + async get(url: string): Promise { + const response = await this.context.get(url); + return this.handleResponse(response); + } + + /** + * Performs a POST request with the given payload to the specified URL. + * @param {string} url The endpoint URL to send the request to. + * @param {Record} payload The data payload to send with the request. + * @returns {Promise} A Promise that resolves to the response JSON. + * @throws {Error} If the response indicates failure. + */ + async post(url: string, payload: Record): Promise { + const response = await this.context.post(url, { data: payload }); + return this.handleResponse(response); + } + + /** + * Performs a PUT request with the given payload to the specified URL. + * @param {string} url The endpoint URL to send the request to. + * @param {Record} payload The data payload to send with the request. + * @returns {Promise} A Promise that resolves to the response JSON. + * @throws {Error} If the response indicates failure. + */ + async put(url: string, payload: Record): Promise { + const response = await this.context.put(url, { data: payload }); + return this.handleResponse(response); + } + + /** + * Performs a DELETE request to the specified URL. + * @param {string} url The endpoint URL to send the request to. + * @returns {Promise} A Promise indicating successful deletion. + */ + async delete(url: string): Promise { + const response = await this.context.delete(url); + return this.handleResponse(response); + } + + /** + * Handles an API response, checking for success and parsing the JSON body. + * @param {APIResponse} response The response object to handle. + * @returns {Promise} A Promise that resolves to the response JSON. + * @throws {Error} If the response is not successful. + */ + async handleResponse(response: APIResponse): Promise { + if (!response.ok()) { + const body = await response.text(); + throw new Error(`API call failed [${response.status()}]: ${body}`); + } + return await response.json(); + } + + /** + * Disposes of the current request context. + * @returns {Promise} + */ + async dispose(): Promise { + await this.context.dispose(); + } +} + +export default ApiClient; \ No newline at end of file diff --git a/dev/tests/e2e/tests/utils/env.utils.ts b/dev/tests/e2e/tests/utils/env.utils.ts new file mode 100644 index 00000000000..647c7590fe6 --- /dev/null +++ b/dev/tests/e2e/tests/utils/env.utils.ts @@ -0,0 +1,14 @@ +// @ts-check + +/** + * Utility to retrieve required environment variables. + * Throws an error when the variable is not set. + */ +export function requireEnv(varName: string): string { + const value = process.env[varName]; + if (!value) { + throw new Error(`${varName} is not defined in the .env file.`); + } + return value; +} + diff --git a/dev/tests/e2e/tests/utils/logger/Logger.ts b/dev/tests/e2e/tests/utils/logger/Logger.ts new file mode 100644 index 00000000000..5446ff05b9d --- /dev/null +++ b/dev/tests/e2e/tests/utils/logger/Logger.ts @@ -0,0 +1,67 @@ +// @ts-check + +export class Logger { + private readonly context: string; + private readonly isDebug: boolean; + + constructor(context: string) { + this.context = context; + this.isDebug = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'; + } + + private formatMessage(level: string, args: unknown[]): string { + const prefix = `[${this.context}] [${level.toUpperCase()}]`; + const message = args.map(arg => { + try { + return typeof arg === 'string' ? arg : JSON.stringify(arg); + } catch { + return '[Unserializable]'; + } + }).join(' '); + return `${prefix} ${message}\n`; + } + + private write(level: string, args: unknown[]): void { + if (!this.isDebug && level === 'log') { + return; + } + const msg = this.formatMessage(level, args); + if (typeof process !== 'undefined' && process.stdout?.write) { + if (level === 'error') { + process.stderr.write(msg); + } else { + process.stdout.write(msg); + } + } else { + // eslint-disable-next-line no-console + switch (level) { + case 'log': + case 'info': + console.info(msg.trim()); + break; + case 'warn': + console.warn(msg.trim()); + break; + case 'error': + console.error(msg.trim()); + break; + } + } + } + + public log(...args: unknown[]): void { + this.write('log', args); + } + + public info(...args: unknown[]): void { + this.write('info', args); + } + + public warn(...args: unknown[]): void { + this.write('warn', args); + } + + public error(...args: unknown[]): void { + this.write('error', args); + } +} diff --git a/dev/tests/e2e/tests/utils/logger/factory.ts b/dev/tests/e2e/tests/utils/logger/factory.ts new file mode 100644 index 00000000000..a5936a4691e --- /dev/null +++ b/dev/tests/e2e/tests/utils/logger/factory.ts @@ -0,0 +1,7 @@ +// @ts-check + +import { Logger } from './Logger'; + +export function createLogger(context: string): Logger { + return new Logger(context); +} diff --git a/dev/tests/e2e/tests/utils/logger/index.ts b/dev/tests/e2e/tests/utils/logger/index.ts new file mode 100644 index 00000000000..86c314c5310 --- /dev/null +++ b/dev/tests/e2e/tests/utils/logger/index.ts @@ -0,0 +1,4 @@ +// @ts-check + +export { Logger } from './Logger'; +export { createLogger } from './factory'; \ No newline at end of file diff --git a/dev/tests/e2e/tests/utils/magewire.utils.ts b/dev/tests/e2e/tests/utils/magewire.utils.ts new file mode 100644 index 00000000000..ab3ed91ede6 --- /dev/null +++ b/dev/tests/e2e/tests/utils/magewire.utils.ts @@ -0,0 +1,89 @@ +// @ts-check + +import {expect, Page} from '@playwright/test'; + +class MagewireUtils { + + protected page: Page; + private activeRequests: Set = new Set(); + + constructor(page: Page) { + this.page = page; + } + + /** + * Sets up request/response monitoring for Magewire traffic. + * Must be called before Magewire activity starts (e.g. in beforeEach). + */ + startMonitoring(): void { + const handleMagewireTraffic = (type: 'add' | 'delete') => (event: { url(): string }) => { + const url = event.url(); + if (this.isMagewireRequest(url)) { + this.activeRequests[type](url); + } + }; + + this.page.on('request', handleMagewireTraffic('add')); + this.page.on('response', handleMagewireTraffic('delete')); + this.page.on('requestfailed', handleMagewireTraffic('delete')); + } + + /** + * Waits until all Magewire network requests are completed. + */ + async waitForMagewireRequests(): Promise { + const settlingTime = 100; // ms to wait after last request seen + const maxWaitTime = 10000; // total timeout + const checkInterval = 50; // interval to check active requests + + const start = Date.now(); + + while (Date.now() - start <= maxWaitTime) { + if (this.activeRequests.size === 0) { + // Wait a little to ensure no new requests are triggered + await this.page.waitForTimeout(settlingTime); + if (this.activeRequests.size === 0) { + await this.waitForMagewireDomIdle(); + return; + } + } + + await this.page.waitForTimeout(checkInterval); + } + + throw new Error('[Magewire] Timeout: Still pending requests after wait'); + } + + private async waitForMagewireDomIdle(): Promise { + const element = this.page.locator('.magewire.messenger'); + + // LocatorHandler will keep looking for pop-up + await this.page.addLocatorHandler(element, async() => { + // Keep retrying, waiting for element to be hidden. + await expect(async () => { + await expect(element).toBeHidden(); + }).toPass(); + }, {noWaitAfter: true}) + } + + // private async waitForMagewireDomIdle(): Promise { + // // 1. Check of de messenger height 0px is + // await this.page.waitForFunction(() => { + // const element = document.querySelector('.magewire\\.messenger'); + // return element && getComputedStyle(element).height === '0px'; + // }, { timeout: 30000 }); + // + // // 2. Check if there is no processing ongoing + // await this.page.waitForFunction(() => { + // return !(window.magewire && (window.magewire as any).processing); + // }, { timeout: 30000 }); + // + // await this.page.waitForTimeout(500); + // } + + private isMagewireRequest(url: string): boolean { + return url.includes('/magewire/message'); + } +} + +export default MagewireUtils; diff --git a/dev/tests/e2e/tests/utils/notificationValidator.utils.ts b/dev/tests/e2e/tests/utils/notificationValidator.utils.ts new file mode 100644 index 00000000000..e952c38dfe7 --- /dev/null +++ b/dev/tests/e2e/tests/utils/notificationValidator.utils.ts @@ -0,0 +1,45 @@ +// @ts-check + +import { expect, Page, TestInfo } from "@playwright/test"; +import { UIReference } from '@config'; + +class NotificationValidatorUtils { + + private page : Page; + private testInfo: TestInfo; + + constructor(page: Page, testInfo: TestInfo) { + this.page = page; + this.testInfo = testInfo; + } + + /** + * @param notificationType + * @param value + * @return json object + */ + async validate(notificationType: string, value: string) { + return; + // await this.page.locator(UIReference.general.messageLocator).waitFor({ state: 'visible' }); + // const notificationText = await this.page.locator(UIReference.general.messageLocator).textContent(); + // let message = { success: true, message: 'Action was successful, but notification text could not be extracted.'}; + // + // if( + // notificationText !== null + // ) { + // message = { success: true, message: notificationText.trim()}; + // } + // + // if ( + // ! expect.soft(this.page.locator(UIReference.general.messageLocator)).toContainText(value) + // ) { + // message = { success: false, message: `Notification text not found: ${value}. Found notification text: ${notificationText}` }; + // } + // + // this.testInfo.annotations.push({ type: `Notification: ${notificationType}`, description: message.message }); + // + // return message; + } +} + +export default NotificationValidatorUtils; diff --git a/dev/tests/e2e/translate-json.js b/dev/tests/e2e/translate-json.js new file mode 100644 index 00000000000..7c122833e08 --- /dev/null +++ b/dev/tests/e2e/translate-json.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const csv = require('csv-parse/sync'); + +// Check if locale argument is provided +if (process.argv.length < 3) { + console.error('Please provide a locale argument (e.g., nl_NL)'); + process.exit(1); +} + +class TranslateJson { + + pathToBaseDir = '../../../../../../../'; // default: when installed via npm (magento2 root folder) + locale = 'en_US'; + + baseSourcePath = 'base-tests/config'; + baseDestinationPath = 'tests/config'; + jsonFiles = [ + 'element-identifiers.json', + 'outcome-markers.json' + ]; + + + constructor() { + const isLocalDev = fs.existsSync(path.resolve(__dirname, '.git')); + + // Change paths when running translation script in gitrepo. + if (isLocalDev) { + this.pathToBaseDir = './i18n/'; + this.baseSourcePath = 'tests/config'; + this.baseDestinationPath = 'base-tests/config'; + } + + this.locale = process.argv[2]; + } + + // Main execution + main() { + try { + // Find and parse CSV files + const appCsvFiles = this.findCsvFiles(this.pathToBaseDir + 'app', this.locale); + const vendorCsvFiles = this.findCsvFiles(this.pathToBaseDir + 'vendor', this.locale); + + let appTranslations = {}; + let vendorTranslations = {}; + + // Parse app translations + for (const file of appCsvFiles) { + const translations = this.parseCsvFile(file); + appTranslations = { ...appTranslations, ...translations }; + } + + // Parse vendor translations + for (const file of vendorCsvFiles) { + try { + const translations = this.parseCsvFile(file); + vendorTranslations = { ...vendorTranslations, ...translations }; + } catch (error) { + console.error(`Error processing vendor file ${file}:`, error.message); + } + } + + // Merge translations with app taking precedence + const translations = this.mergeTranslations(appTranslations, vendorTranslations); + + // Process JSON files + for (const fileName of this.jsonFiles) { + const sourcePath = path.resolve(this.baseSourcePath, fileName); + const destPath = path.resolve(this.baseDestinationPath, fileName); + + const content = JSON.parse(fs.readFileSync(sourcePath, 'utf-8')); + let translatedContent = this.translateObject(content, translations); + + // Read existing translations if the file exists + if (fs.existsSync(destPath)) { + const existingContent = JSON.parse(fs.readFileSync(destPath, 'utf-8')); + + // Combine existing and new translations, preserving existing ones + translatedContent = this.mergeTranslations(translatedContent, existingContent); + } + + // Ensure target directory exists + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + + fs.writeFileSync(destPath, JSON.stringify(translatedContent, null, 2)); + console.log(`Translated file written: ${destPath}`); + } + + console.log('Translation completed successfully!'); + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } + } + + // Function to find CSV files recursively + findCsvFiles(dir, locale) { + let results = []; + if (!fs.statSync(dir).isDirectory()) { + return results; + } + + const files = fs.readdirSync(dir); + + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + results = results.concat(this.findCsvFiles(filePath, locale)); + } else if (file === `${locale}.csv`) { + results.push(filePath); + } + } + + return results; + } + + parseCsvFile(filePath) { + const relativeFilePath = filePath.replace('../../../../../../../', ''); + console.log("Translating file: ", relativeFilePath); + + const content = fs.readFileSync(filePath, 'utf-8'); + const records = csv.parse(content, { + skip_empty_lines: true, + trim: true + }); + + const translations = {}; + for (const [index, record] of records.entries()) { + const [key, value] = record; + translations[key] = value; + } + + console.log("Done..."); + + return translations; + } + + // Function to deeply merge translations with specified precedence + mergeTranslations(primaryTranslations, secondaryTranslations) { + const result = { ...secondaryTranslations }; + + for (const [key, value] of Object.entries(primaryTranslations)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + result[key] = this.mergeTranslations(value, result[key] || {}); + } else { + if (!result.hasOwnProperty(key)) { + result[key] = value; + } + } + } + + return result; + } + + // Function to translate values in an object recursively + translateObject(obj, translations) { + if (typeof obj === 'string') { + return translations[obj] ?? obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => this.translateObject(item, translations)).filter(item => item !== null); + } + + if (typeof obj === 'object' && obj !== null) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const translatedValue = this.translateObject(value, translations); + if (translatedValue !== null) { + result[key] = translatedValue; + } + } + return result; + } + + return null; + } +} + +const translateJson = new TranslateJson(); +translateJson.main(); \ No newline at end of file diff --git a/dev/tests/e2e/tsconfig.example.json b/dev/tests/e2e/tsconfig.example.json new file mode 100644 index 00000000000..9becd5d66dd --- /dev/null +++ b/dev/tests/e2e/tsconfig.example.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@config": [ + "base-tests/config", + "tests/config", // keep this for gitlab and when index.ts needs to be changed. + ], + "@utils/*": [ + "base-tests/utils/*", + "tests/utils/*" + ], + "@poms/*": [ + "base-tests/poms/*", + "tests/poms/*" + ], + "@types/*": [ + "base-tests/types/*", + "tests/types/*" + ], + "@fixtures/*": [ + "base-tests/fixtures/*", + "tests/fixtures/*" + ] + } + } +} \ No newline at end of file diff --git a/dev/tests/e2e/tsconfig.json b/dev/tests/e2e/tsconfig.json new file mode 100644 index 00000000000..9becd5d66dd --- /dev/null +++ b/dev/tests/e2e/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@config": [ + "base-tests/config", + "tests/config", // keep this for gitlab and when index.ts needs to be changed. + ], + "@utils/*": [ + "base-tests/utils/*", + "tests/utils/*" + ], + "@poms/*": [ + "base-tests/poms/*", + "tests/poms/*" + ], + "@types/*": [ + "base-tests/types/*", + "tests/types/*" + ], + "@fixtures/*": [ + "base-tests/fixtures/*", + "tests/fixtures/*" + ] + } + } +} \ No newline at end of file