diff --git a/features/login.feature b/features/login.feature index fb9f1fa5..7cdb8f74 100644 --- a/features/login.feature +++ b/features/login.feature @@ -4,9 +4,12 @@ Feature: Login Feature Given I open the "https://www.saucedemo.com/" page Scenario: Validate the login page title - # TODO: Fix this failing scenario - Then I should see the title "Labs Swag" + Then I should see the title "Swag Labs" Scenario: Validate login error message Then I will login as 'locked_out_user' - # TODO: Add a step to validate the error message received \ No newline at end of file + Then I should see the error message "Epic sadface: Sorry, this user has been locked out." + + Scenario: Validate valid account login + Then I will login as 'standard_user' + Then I should see the Inventory page "https://www.saucedemo.com/inventory.html" \ No newline at end of file diff --git a/features/product.feature b/features/product.feature index 8a7ceab9..50651fb7 100644 --- a/features/product.feature +++ b/features/product.feature @@ -3,11 +3,22 @@ Feature: Product Feature Background: Given I open the "https://www.saucedemo.com/" page - # Create a datatable to validate the Price (high to low) and Price (low to high) sort options (top-right) using a Scenario Outline - Scenario Outline: Validate product sort by price - Then I will login as 'standard_user' - # TODO: Sort the items by - # TODO: Validate all 6 items are sorted correctly by price + Scenario Outline: Validate product sort by price + Then I will login as 'standard_user' + Then I will sort the products by '' + Then I should see the products sorted by '' + + Examples: + | sort | + | Price (high to low) | + | Price (low to high) | + + Scenario Outline: Validate product sort by name + Then I will login as 'standard_user' + Then I will sort the products by '' + Then I should see the products sorted by '' + Examples: - # TODO: extend the datatable to paramterize this test - | sort | \ No newline at end of file + | sort | + | Name (A to Z) | + | Name (Z to A) | \ No newline at end of file diff --git a/features/purchase.feature b/features/purchase.feature index 28634789..62da6062 100644 --- a/features/purchase.feature +++ b/features/purchase.feature @@ -6,9 +6,9 @@ Feature: Purchase Feature Scenario: Validate successful purchase text Then I will login as 'standard_user' Then I will add the backpack to the cart - # TODO: Select the cart (top-right) - # TODO: Select Checkout - # TODO: Fill in the First Name, Last Name, and Zip/Postal Code - # TODO: Select Continue - # TODO: Select Finish - # TODO: Validate the text 'Thank you for your order!' \ No newline at end of file + Then I will click the cart icon + Then I will click Checkout + Then I will fill in first name as 'Noble', last name as 'Obodum', and zip code as '28206' + Then I will click Continue + Then I will click Finish + Then I should see the confirmation message 'Thank you for your order!' \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b90d7c6c..23b43aa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "Playwright-Project", + "name": "Playwright-Cucumber-Exercise-Noble", "lockfileVersion": 3, "requires": true, "packages": { @@ -189,6 +189,7 @@ "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-10.0.1.tgz", "integrity": "sha512-g7W7SQnNMSNnMRQVGubjefCxdgNFyq4P3qxT2Ve7Xhh8ZLoNkoRDcWsyfKQVWnxNfgW3aGJmxbucWRoTi+ZUqg==", "dev": true, + "peer": true, "dependencies": { "@cucumber/ci-environment": "9.2.0", "@cucumber/cucumber-expressions": "16.1.2", @@ -253,6 +254,7 @@ "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-26.2.0.tgz", "integrity": "sha512-iRSiK8YAIHAmLrn/mUfpAx7OXZ7LyNlh1zT89RoziSVCbqSVDxJS6ckEzW8loxs+EEXl0dKPQOXiDmbHV+C/fA==", "dev": true, + "peer": true, "dependencies": { "@cucumber/messages": ">=19.1.4 <=22" } @@ -350,6 +352,7 @@ "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz", "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, + "peer": true, "peerDependencies": { "@cucumber/messages": ">=17.1.1" } @@ -359,6 +362,7 @@ "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-22.0.0.tgz", "integrity": "sha512-EuaUtYte9ilkxcKmfqGF9pJsHRUU0jwie5ukuZ/1NPTuHS1LxHPsGEODK17RPRbZHOFhqybNzG2rHAwThxEymg==", "dev": true, + "peer": true, "dependencies": { "@types/uuid": "9.0.1", "class-transformer": "0.5.1", @@ -549,6 +553,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.3.tgz", "integrity": "sha512-XJavIpZqiXID5Yxnxv3RUDKTN5b81ddNC3ecsA0SoFXz/QU8OGBwZGMomiq0zw+uuqbL/krztv/DINAQ/EV4gg==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -974,6 +979,7 @@ "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-21.0.1.tgz", "integrity": "sha512-pGR7iURM4SF9Qp1IIpNiVQ77J9kfxMkPOEbyy+zRmGABnWWCsqMpJdfHeh9Mb3VskemVw85++e15JT0PYdcR3g==", "dev": true, + "peer": true, "dependencies": { "@types/uuid": "8.3.4", "class-transformer": "0.5.1", @@ -2473,6 +2479,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/pages/login.page.ts b/pages/login.page.ts index 5a01614b..c3a5265d 100644 --- a/pages/login.page.ts +++ b/pages/login.page.ts @@ -6,6 +6,7 @@ export class Login { private readonly passwordField: string = 'input[id="password"]' private readonly userNameField: string = 'input[id="user-name"]' private readonly loginButton: string = 'input[id="login-button"]' + private readonly errorMessage: string = '[data-test="error"]' constructor(page: Page) { this.page = page; @@ -23,4 +24,14 @@ export class Login { await this.page.locator(this.passwordField).fill(this.password) await this.page.locator(this.loginButton).click() } + + public async validateErrorMessage(expectedMessage: string) { + const errorLocator = this.page.locator(this.errorMessage) + await errorLocator.waitFor({ state: 'visible' }) + const actualMessage = (await errorLocator.textContent())?.trim() + + if (actualMessage !== expectedMessage) { + throw new Error(`Expected error message to be ${expectedMessage} but found ${actualMessage}`) + } + } } \ No newline at end of file diff --git a/pages/product.page.ts b/pages/product.page.ts index 14bedb1b..3f91f869 100644 --- a/pages/product.page.ts +++ b/pages/product.page.ts @@ -3,6 +3,9 @@ import { Page } from "@playwright/test" export class Product { private readonly page: Page private readonly addToCart: string = 'button[id="add-to-cart-sauce-labs-backpack"]' + private readonly sortDropdown: string = 'select[data-test="product-sort-container"]' + private readonly productPrices: string = '.inventory_item_price' + private readonly productNames: string = '.inventory_item_name' constructor(page: Page) { this.page = page; @@ -11,4 +14,70 @@ export class Product { public async addBackPackToCart() { await this.page.locator(this.addToCart).click() } + + public async sortProductsByOption(sortOption: string) { + const dropdown = this.page.locator(this.sortDropdown) + await dropdown.waitFor({ state: 'visible' }) + await dropdown.selectOption({ value: this.getSortOptionValue(sortOption) }) + } + + public async validateProductsSorted(sortOption: string) { + const normalized = sortOption.toLowerCase() + + if (normalized.includes('price')) { + await this.validatePriceSort(sortOption) + return + } + + if (normalized.includes('name')) { + await this.validateNameSort(sortOption) + return + } + + throw new Error(`Unsupported sort option: ${sortOption}`) + } + + private async validatePriceSort(sortOption: string) { + const prices = await this.page.locator(this.productPrices).allTextContents() + const values = prices.map((value) => Number(value.replace('$', ''))) + const sortedValues = [...values].sort((a, b) => a - b) + const expectedValues = this.isHighToLow(sortOption) ? [...sortedValues].reverse() : sortedValues + + if (JSON.stringify(values) !== JSON.stringify(expectedValues)) { + throw new Error(`Expected prices to be sorted ${sortOption}, but found ${values.join(', ')}`) + } + } + + private async validateNameSort(sortOption: string) { + const names = await this.page.locator(this.productNames).allTextContents() + const normalizedNames = names.map((name) => name.trim().toLowerCase()) + const sortedNames = [...normalizedNames].sort((a, b) => a.localeCompare(b)) + const expectedNames = this.isNameDescending(sortOption) ? [...sortedNames].reverse() : sortedNames + + if (JSON.stringify(normalizedNames) !== JSON.stringify(expectedNames)) { + throw new Error(`Expected names to be sorted ${sortOption}, but found ${names.join(', ')}`) + } + } + + private getSortOptionValue(sortOption: string): string { + const normalized = sortOption.toLowerCase() + + if (normalized.includes('price')) { + return normalized.includes('high') ? 'hilo' : 'lohi' + } + + if (normalized.includes('name')) { + return normalized.includes('z to a') || normalized.includes('z') ? 'za' : 'az' + } + + throw new Error(`Unsupported sort option: ${sortOption}`) + } + + private isHighToLow(sortOption: string): boolean { + return sortOption.toLowerCase().includes('high') + } + + private isNameDescending(sortOption: string): boolean { + return sortOption.toLowerCase().includes('z') + } } \ No newline at end of file diff --git a/pages/purchase.page.ts b/pages/purchase.page.ts new file mode 100644 index 00000000..e9797fef --- /dev/null +++ b/pages/purchase.page.ts @@ -0,0 +1,57 @@ +import { Page } from "@playwright/test" + +export class Purchase { + private readonly page: Page + private readonly goToCart: string = 'a.shopping_cart_link' + private readonly checkoutButton: string = 'button[data-test="checkout"]' + private readonly firstNameField: string = 'input[id="first-name"]' + private readonly lastNameField: string = 'input[id="last-name"]' + private readonly zipField: string = 'input[id="postal-code"]' + private readonly continueButton: string = 'input[data-test="continue"]' + private readonly finishButton: string = 'button[data-test="finish"]' + private readonly confirmationMessage: string = 'h2.complete-header' + + constructor(page: Page) { + this.page = page; + } + + public async goToCartPage() { + const cartLink = this.page.locator(this.goToCart) + await cartLink.waitFor({ state: 'visible' }) + await cartLink.click() + } + + public async clickCheckout() { + const checkout = this.page.locator(this.checkoutButton) + await checkout.waitFor({ state: 'visible' }) + await checkout.click() + } + + public async fillCheckoutForm(firstName: string, lastName: string, zip: string) { + await this.page.locator(this.firstNameField).fill(firstName) + await this.page.locator(this.lastNameField).fill(lastName) + await this.page.locator(this.zipField).fill(zip) + } + + public async clickContinue() { + const continueButton = this.page.locator(this.continueButton) + await continueButton.waitFor({ state: 'visible' }) + await continueButton.click() + } + + public async clickFinish() { + const finishButton = this.page.locator(this.finishButton) + await finishButton.waitFor({ state: 'visible' }) + await finishButton.click() + } + + public async validateConfirmationMessage(expectedMessage: string) { + const confirmation = this.page.locator(this.confirmationMessage) + await confirmation.waitFor({ state: 'visible' }) + const actualMessage = (await confirmation.textContent())?.trim() + + if (actualMessage !== expectedMessage) { + throw new Error(`Expected text to be ${expectedMessage} but found ${actualMessage}`) + } + } +} \ No newline at end of file diff --git a/playwrightUtilities.ts b/playwrightUtilities.ts index 93244a29..9670be83 100644 --- a/playwrightUtilities.ts +++ b/playwrightUtilities.ts @@ -4,9 +4,22 @@ let browser: Browser | null = null; let page: Page | null = null; const DEFAULT_TIMEOUT = 30000; +const launchOptions = { + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}; + export const initializeBrowser = async () => { if (!browser) { - browser = await chromium.launch({ headless: false }); + try { + browser = await chromium.launch({ + ...launchOptions, + channel: 'chrome', + }); + } catch (error) { + console.warn('System Chrome was not available, falling back to bundled Chromium.', error); + browser = await chromium.launch(launchOptions); + } } }; diff --git a/steps/login.steps.ts b/steps/login.steps.ts index c2aa0d80..ff0da77c 100644 --- a/steps/login.steps.ts +++ b/steps/login.steps.ts @@ -8,4 +8,12 @@ Then('I should see the title {string}', async (expectedTitle) => { Then('I will login as {string}', async (userName) => { await new Login(getPage()).loginAsUser(userName); +}); + +Then('I should see the error message {string}', async (expectedMessage) => { + await new Login(getPage()).validateErrorMessage(expectedMessage); +}); + +Then('I should see the Inventory page {string}', async (url) => { + await getPage().goto(url); }); \ No newline at end of file diff --git a/steps/product.steps.ts b/steps/product.steps.ts index bb52fb98..c1c7c2cd 100644 --- a/steps/product.steps.ts +++ b/steps/product.steps.ts @@ -4,4 +4,12 @@ import { Product } from '../pages/product.page'; Then('I will add the backpack to the cart', async () => { await new Product(getPage()).addBackPackToCart(); +}); + +Then('I will sort the products by {string}', async (sortOption: string) => { + await new Product(getPage()).sortProductsByOption(sortOption); +}); + +Then('I should see the products sorted by {string}', async (sortOption: string) => { + await new Product(getPage()).validateProductsSorted(sortOption); }); \ No newline at end of file diff --git a/steps/purchase.steps.ts b/steps/purchase.steps.ts new file mode 100644 index 00000000..050d98a8 --- /dev/null +++ b/steps/purchase.steps.ts @@ -0,0 +1,30 @@ +import { Then } from '@cucumber/cucumber'; +import { getPage } from '../playwrightUtilities'; +import { Purchase } from '../pages/purchase.page'; + +Then('I will click the cart icon', async () => { + await new Purchase(getPage()).goToCartPage(); +}); + +Then('I will click Checkout', async () => { + await new Purchase(getPage()).clickCheckout(); +}); + +Then( + 'I will fill in first name as {string}, last name as {string}, and zip code as {string}', + async (firstName: string, lastName: string, zip: string) => { + await new Purchase(getPage()).fillCheckoutForm(firstName, lastName, zip) + } +) + +Then('I will click Continue', async () => { + await new Purchase(getPage()).clickContinue(); +}); + +Then('I will click Finish', async () => { + await new Purchase(getPage()).clickFinish(); +}); + +Then('I should see the confirmation message {string}', async (expectedMessage: string) => { + await new Purchase(getPage()).validateConfirmationMessage(expectedMessage); +}); \ No newline at end of file