diff --git a/.github/workflows/cucumber-report.yml b/.github/workflows/cucumber-report.yml new file mode 100644 index 0000000..df7ea5a --- /dev/null +++ b/.github/workflows/cucumber-report.yml @@ -0,0 +1,42 @@ +name: Cucumber Report + +on: + pull_request: + branches: ["main", "master"] + workflow_dispatch: + +jobs: + test-and-upload-report: + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Run cucumber tests + env: + CI: "true" + run: npm test + + - name: Upload cucumber report + if: success() + uses: actions/upload-artifact@v4 + with: + name: cucumber-report + path: | + cucumber_report.html + report.json + diff --git a/.github/workflows/dispatch-to-grader.yml b/.github/workflows/dispatch-to-grader.yml index c72def8..971a034 100644 --- a/.github/workflows/dispatch-to-grader.yml +++ b/.github/workflows/dispatch-to-grader.yml @@ -12,6 +12,8 @@ on: jobs: notify-grader: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - name: Verify token can access private grader repo run: | @@ -21,7 +23,7 @@ jobs: echo "HTTP $code"; test "$code" = "200" - name: Send repository_dispatch to grader - uses: peter-evans/repository-dispatch@v3 + uses: peter-evans/repository-dispatch@v4 with: token: ${{ secrets.GRADER_REPO_PAT }} repository: automationExamples/Playwright-Cucumber-Exercise-Eval diff --git a/cucumber.js b/cucumber.js deleted file mode 100644 index e8192d6..0000000 --- a/cucumber.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - default: `--require-module ts-node/register --require './steps/**/*.ts' --require './hooks/**/*.ts --format @cucumber/pretty-formatter` -}; \ No newline at end of file diff --git a/features/cart.feature b/features/cart.feature new file mode 100644 index 0000000..732ce33 --- /dev/null +++ b/features/cart.feature @@ -0,0 +1,16 @@ +Feature: Shopping cart + + Background: + Given I open the "https://www.saucedemo.com/" page + + Scenario: Add item, open cart, remove item, and continue shopping + When [Login page] I will login as 'standard_user' + When [Products page] I click 'Add to Cart' for 'Sauce Labs Backpack' item + When [Products page] I click the cart icon + Then [Cart] page should be open + And [Cart] 'Sauce Labs Backpack' should be present + When [Cart] I click 'Remove' for 'Sauce Labs Backpack' item + Then [Cart] 'Sauce Labs Backpack' should not be present + When [Cart] I click 'Continue shopping' + Then [Products page] page should be open + diff --git a/features/login.feature b/features/login.feature index fb9f1fa..8481abb 100644 --- a/features/login.feature +++ b/features/login.feature @@ -3,10 +3,17 @@ Feature: Login Feature Background: Given I open the "https://www.saucedemo.com/" page + # The actual title is "Swag Labs" (not "Labs Swag"). Step [Login page] clarifies which page title is checked. Scenario: Validate the login page title - # TODO: Fix this failing scenario - Then I should see the title "Labs Swag" + Then [Login page] "Swag Labs" title should be present - 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 + Scenario Outline: Validate login error message + When [Login page] I login with UserName "" and Password "" + Then [Login page] I should see the error message "" + Examples: + | userName | passwordType | errorMessage | + | standard_user | invalid | Epic sadface: Username and password do not match any user in this service | + | Invalid_user | valid | Epic sadface: Username and password do not match any user in this service | + | locked_out_user | | Epic sadface: Password is required | + | | valid | Epic sadface: Username is required | + | | | Epic sadface: Username is required | diff --git a/features/product.feature b/features/product.feature index 8a7ceab..14136ca 100644 --- a/features/product.feature +++ b/features/product.feature @@ -1,13 +1,78 @@ -Feature: Product Feature +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 - Examples: - # TODO: extend the datatable to paramterize this test - | sort | \ No newline at end of file + Scenario Outline: Validate product sort by price + When [Login page] I will login as 'standard_user' + Then [Products page] Page should be opened + And [Products page] '6' Products should be present on the page + When [Products page] I click on 'Sort' dropdown button + Then [Products page] 'Sort' dropdown menu should be present with next options: + | Options | + | Name (A to Z) | + | Name (Z to A) | + | Price (low to high) | + | Price (high to low) | + When [Products page] I click on '' option + Then [Products page] 'Sort' dropdown menu should be closed + And [Products page] '' value should be present in the 'Sort' dropdown field + And [Products page] Products should be sorted with the next values: + | Name | Price | + | | | + | | | + | | | + | | | + | | | + | | | + + Examples: + | sort | productName1 | productPrice1 | productName2 | productPrice2 | productName3 | productPrice3 | productName4 | productPrice4 | productName5 | productPrice5 | productName6 | productPrice6 | + | Price (low to high) | Sauce Labs Onesie | $7.99 | Sauce Labs Bike Light | $9.99 | Sauce Labs Bolt T-Shirt | $15.99 | Test.allTheThings() T-Shirt (Red) | $15.99 | Sauce Labs Backpack | $29.99 | Sauce Labs Fleece Jacket | $49.99 | + | Price (high to low) | Sauce Labs Fleece Jacket | $49.99 | Sauce Labs Backpack | $29.99 | Sauce Labs Bolt T-Shirt | $15.99 | Test.allTheThings() T-Shirt (Red) | $15.99 | Sauce Labs Bike Light | $9.99 | Sauce Labs Onesie | $7.99 | + + Scenario: Add item then remove it from products page + When [Login page] I will login as 'standard_user' + Then [Products page] Page should be opened + When [Products page] I click 'Add to Cart' for 'Sauce Labs Backpack' item + Then [Products page] I should see '1' in the cart badge + When [Products page] I click 'Remove' for 'Sauce Labs Backpack' item + When [Products page] I click the cart icon + Then [Cart] 'Sauce Labs Backpack' should not be present + + Scenario: Open product details page and add/remove item + When [Login page] I will login as 'standard_user' + Then [Products page] Page should be opened + When [Products page] I click image for 'Sauce Labs Bike Light' item + Then [Product details page] should be open + And [Product details page] should show expected content: + | Name | Description | Price | + | Sauce Labs Bike Light | A red light isn't the desired state in testing but it sure helps when riding your bike at night. Water-resistant with 3 lighting modes, 1 AAA battery included. | $9.99 | + When [Product details page] I click 'Add to Cart' button + Then [Products page] I should see '1' in the cart badge + When [Product details page] I click 'Remove' button + Then [Products page] cart badge should be removed + When [Product details page] I click 'Back to products' button + Then [Products page] page should be open + + Scenario: Verify navigation menu open and close + When [Login page] I will login as 'standard_user' + Then [Products page] Page should be opened + When [Products page] I click navigation bar button + Then [Navigation] should be open + And [Navigation] options should be present with next options: + | Options | + | All Items | + | About | + | Logout | + | Reset App State | + When [Navigation] I click close + Then [Navigation] should be closed + + Scenario: Logout from navigation + When [Login page] I will login as 'standard_user' + Then [Products page] Page should be opened + When [Products page] I click navigation bar button + When [Navigation] I click logout + Then [Login page] "Swag Labs" title should be present + diff --git a/features/purchase.feature b/features/purchase.feature index 2863478..98bdd2a 100644 --- a/features/purchase.feature +++ b/features/purchase.feature @@ -1,14 +1,117 @@ -Feature: Purchase Feature +Feature: Purchase Feature Background: Given I open the "https://www.saucedemo.com/" page - 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 + Scenario: Validate successful purchase text + # this step log in with user for me is more list when Step due to it just user action and not validation + When [Login page] I will login as 'standard_user' + # this step log in with user for me is more list when Step due to it just user action and not validation + # When [Products page] I will add the backpack to the cart + When [Products page] I click 'Add to Cart' for 'Sauce Labs Backpack' item + Then [Products page] I should see '1' in the cart badge + When [Products page] I click the cart icon + Then [Cart] page should be open + And [Cart] 'Sauce Labs Backpack' should be present + When [Cart] I click 'Checkout' + Then [Checkout] page should be open + When [Checkout] I fill the checkout page with next value: + | First Name | Last Name | Zip code | + | Standard | Standrad Last | 000000 | + Then [Checkout] I click on 'Continue' button + And [Checkout] 'Payment Information' section should be present + And [Checkout] 'Shipping Information:' section should be present + And [Checkout] 'Price Total' should be present with next values: + | Item total | Tax | Total | + | $29.99 | $2.40 | $32.39 | + When [Checkout] I click on 'Finish' button + Then [Checkout] I should see 'Thank you for your order!' text + # add Back home page step to make sure that the user can navigate back to home page after purchase + When [Checkout] I click on 'Back Home' button + Then [Products page] page should be open + + Scenario: Multi-item cart, continue shopping, and checkout totals + When [Login page] I will login as 'standard_user' + When [Products page] I click 'Add to Cart' for 'Sauce Labs Backpack' item + When [Products page] I click 'Add to Cart' for 'Sauce Labs Bike Light' item + Then [Products page] I should see '2' in the cart badge + When [Products page] I click the cart icon + Then [Cart] page should be open + And [Cart] the following items should be in the cart: + | Item | + | Sauce Labs Backpack | + | Sauce Labs Bike Light | + When [Cart] I click 'Continue shopping' + When [Products page] I click 'Add to Cart' for 'Sauce Labs Bolt T-Shirt' item + Then [Products page] I should see '3' in the cart badge + When [Products page] I click the cart icon + Then [Cart] the following items should be in the cart: + | Item | + | Sauce Labs Backpack | + | Sauce Labs Bike Light | + | Sauce Labs Bolt T-Shirt | + When [Cart] I click 'Checkout' + Then [Checkout] page should be open + When [Checkout] I fill the checkout page with next value: + | First Name | Last Name | Zip code | + | Multi | Buyer | 90210 | + When [Checkout] I click on 'Continue' button + Then [Checkout] 'Payment Information' section should be present + And [Checkout] 'Shipping Information:' section should be present + And [Checkout] order summary should list products: + | Product | + | Sauce Labs Backpack | + | Sauce Labs Bike Light | + | Sauce Labs Bolt T-Shirt | + And [Checkout] 'Price Total' should be present with next values: + | Item total | Tax | Total | + | $55.97 | $4.48 | $60.45 | + + Scenario: Checkout overview then cancel returns to products + When [Login page] I will login as 'standard_user' + When [Products page] I click 'Add to Cart' for 'Sauce Labs Backpack' item + When [Products page] I click 'Add to Cart' for 'Sauce Labs Bike Light' item + Then [Products page] I should see '2' in the cart badge + When [Products page] I click the cart icon + Then [Cart] page should be open + And [Cart] the following items should be in the cart: + | Item | + | Sauce Labs Backpack | + | Sauce Labs Bike Light | + When [Cart] I click 'Checkout' + Then [Checkout] page should be open + When [Checkout] I fill the checkout page with next value: + | First Name | Last Name | Zip code | + | Cancel | Flow | 10001 | + When [Checkout] I click on 'Continue' button + Then [Checkout] 'Payment Information' section should be present + And [Checkout] 'Shipping Information:' section should be present + And [Checkout] order summary should list products: + | Product | + | Sauce Labs Backpack | + | Sauce Labs Bike Light | + And [Checkout] 'Price Total' should be present with next values: + | Item total | Tax | Total | + | $39.98 | $3.20 | $43.18 | + When [Checkout] I click on 'Cancel' button + Then [Products page] page should be open + + Scenario Outline: Verify checkout page errors + When [Login page] I will login as 'standard_user' + When [Products page] I click 'Add to Cart' for 'Sauce Labs Backpack' item + When [Products page] I click the cart icon + Then [Cart] page should be open + When [Cart] I click 'Checkout' + Then [Checkout] page should be open + When [Checkout] I fill the checkout page with next value: + | First Name | Last Name | Zip code | + | | | | + When [Checkout] I click on 'Continue' button + Then [Checkout] I should see error message "" + + Examples: + | firstName | lastName | zipCode | errorMessage | + | | | | Error: First Name is required | + | John | | | Error: Last Name is required | + | John | Doe | | Error: Postal Code is required | + diff --git a/generate-report.js b/generate-report.js index 2df1cc4..9fb7bf0 100644 --- a/generate-report.js +++ b/generate-report.js @@ -6,7 +6,8 @@ const options = { output: 'cucumber_report.html', reportSuiteAsScenarios: true, scenarioTimestamp: true, - launchReport: true, + // In CI we don't want to open a browser/tab automatically. + launchReport: false, metadata: {} }; diff --git a/package-lock.json b/package-lock.json index b90d7c6..57b8b55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,10 @@ { - "name": "Playwright-Project", + "name": "playwright-cucumber-exercise", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "playwright-cucumber-exercise", "devDependencies": { "@cucumber/cucumber": "^10.0.1", "@cucumber/pretty-formatter": "^1.0.0", diff --git a/package.json b/package.json index ed38f6f..41b5fcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "scripts": { - "test": "cucumber-js --format json:report.json --exit", + "test": "cucumber-js --require-module ts-node/register --require ./steps/**/*.ts --require ./hooks/**/*.ts --format json:report.json --exit && npm run report", "report": "node generate-report.js" }, "devDependencies": { diff --git a/pages/cart.page.ts b/pages/cart.page.ts new file mode 100644 index 0000000..59b060b --- /dev/null +++ b/pages/cart.page.ts @@ -0,0 +1,138 @@ +import { expect, Page } from "@playwright/test"; + +export class Cart { + private readonly page: Page; + + private readonly cartItems = ".cart_item"; + private readonly cartItemName = ".inventory_item_name"; + private readonly cartItemPrice = ".inventory_item_price"; + private readonly checkoutButton = "#checkout"; + private readonly continueShoppingButton = "#continue-shopping"; + + constructor(page: Page) { + this.page = page; + } + + public async IsCartPageOpened() { + await expect(this.page).toHaveURL(/\/cart\.html/); + } + + public async IsCartPageContainingItemNamed(itemName: string) { + const items = this.page.locator(this.cartItems); + const count = await items.count(); + + for (let i = 0; i < count; i++) { + const row = items.nth(i); + const actualName = (await row.locator(this.cartItemName).textContent())?.trim() ?? ""; + + if (actualName === itemName) return; + } + + throw new Error(`Cart item "${itemName}" not found on cart page`); + } + + public async ClickCheckoutButton() { + await this.page.locator(this.checkoutButton).click(); + } + + public async ClickContinueShoppingButton() { + await this.page.locator(this.continueShoppingButton).click(); + } + + public async ClickCartPageButtonByLabel(actionLabel: string) { + const normalized = actionLabel.trim().toLowerCase(); + switch (normalized) { + case "checkout": + await this.ClickCheckoutButton(); + return; + case "continue shopping": + await this.ClickContinueShoppingButton(); + return; + default: + throw new Error(`Unsupported cart action "${actionLabel}". Use "Checkout" or "Continue shopping".`); + } + } + + public async ClickRemoveButtonForCartItemNamed(itemName: string) { + const items = this.page.locator(this.cartItems); + const count = await items.count(); + + for (let i = 0; i < count; i++) { + const row = items.nth(i); + const actualName = (await row.locator(this.cartItemName).textContent())?.trim() ?? ""; + + if (actualName !== itemName) continue; + + const removeBtn = row.locator('button[id^="remove-"]'); + + if ((await removeBtn.count()) === 0) { + throw new Error(`Found cart row for "${itemName}" but no remove button`); + } + + await removeBtn.first().click(); + return; + } + + throw new Error(`Cart item "${itemName}" not found — cannot remove`); + } + + public async ClickCartRowButtonByLabelForItemNamed(buttonLabel: string, itemName: string) { + const normalized = buttonLabel.trim().toLowerCase(); + + if (normalized !== "remove") { + throw new Error(`Unsupported cart row action "${buttonLabel}". Use "Remove".`); + } + await this.ClickRemoveButtonForCartItemNamed(itemName); + } + + public async IsCartPageNotContainingItemNamed(itemName: string) { + const items = this.page.locator(this.cartItems); + const count = await items.count(); + + for (let i = 0; i < count; i++) { + const row = items.nth(i); + const actualName = (await row.locator(this.cartItemName).textContent())?.trim() ?? ""; + + if (actualName === itemName) { + throw new Error(`Expected "${itemName}" to be removed from cart, but it is still present`); + } + } + } + + private async ReadCartLineItemRows(): Promise<{ Name: string; Price: string }[]> { + const items = this.page.locator(this.cartItems); + const count = await items.count(); + const result: { Name: string; Price: string }[] = []; + + for (let i = 0; i < count; i++) { + const row = items.nth(i); + const name = (await row.locator(this.cartItemName).textContent())?.trim() ?? ""; + const price = (await row.locator(this.cartItemPrice).textContent())?.trim() ?? ""; + result.push({ Name: name, Price: price }); + } + + return result; + } + + public async IsCartPageContainingExactlyTheseItemNames(expectedNames: string[]) { + const expected = expectedNames.map((n) => n.trim()).filter((n) => n.length > 0); + const rows = await this.ReadCartLineItemRows(); + const actual = rows.map((r) => r.Name); + + if (actual.length !== expected.length) { + throw new Error(`Expected ${expected.length} cart line(s), found ${actual.length}. Actual: ${actual.join(" | ")}`); + } + + const missing = expected.filter((name) => !actual.includes(name)); + + if (missing.length > 0) { + throw new Error(`Missing cart item(s): ${missing.join(", ")}. Actual: ${actual.join(" | ")}`); + } + + const extra = actual.filter((name) => !expected.includes(name)); + + if (extra.length > 0) { + throw new Error(`Unexpected cart item(s): ${extra.join(", ")}`); + } + } +} diff --git a/pages/checkout.page.ts b/pages/checkout.page.ts new file mode 100644 index 0000000..484e503 --- /dev/null +++ b/pages/checkout.page.ts @@ -0,0 +1,149 @@ +import { expect, Page } from "@playwright/test"; + +export class Checkout { + private readonly page: Page; + + private readonly firstNameField = 'input[name="firstName"]'; + private readonly lastNameField = 'input[name="lastName"]'; + private readonly zipCodeField = 'input[name="postalCode"]'; + private readonly continueButton = "#continue"; + private readonly cancelButton = "#cancel"; + private readonly finishButton = "#finish"; + private readonly backHomeButton = "#back-to-products"; + private readonly checkoutSummaryContainer = '[data-test="checkout-summary-container"]'; + private readonly checkoutSummaryItemName = ".cart_list .inventory_item_name"; + private readonly paymentInfoLabel = '[data-test="payment-info-label"]'; + private readonly shippingInfoLabel = '[data-test="shipping-info-label"]'; + private readonly priceTotalLabel = '[data-test="total-info-label"]'; + private readonly subtotalLabel = '[data-test="subtotal-label"]'; + private readonly taxLabel = '[data-test="tax-label"]'; + private readonly totalLabel = '[data-test="total-label"]'; + private readonly completeHeader = 'h2[data-test="complete-header"]'; + private readonly errorBanner = 'h3[data-test="error"]'; + + constructor(page: Page) { + this.page = page; + } + + public async IsCheckoutPageOpened() { + await expect(this.page).toHaveURL(/\/checkout-step-(one|two)\.html/); + } + + public async FillCheckoutCustomerFields(firstName: string, lastName: string, zipCode: string) { + await this.page.locator(this.firstNameField).fill(firstName ?? ""); + await this.page.locator(this.lastNameField).fill(lastName ?? ""); + await this.page.locator(this.zipCodeField).fill(zipCode ?? ""); + } + + /** Only clicks Continue (step-one). Use when validating errors or before waiting for overview. */ + public async ClickCheckoutContinueButton() { + await this.page.locator(this.continueButton).click(); + } + + public async ClickCheckoutCancelButton() { + await this.page.locator(this.cancelButton).click(); + } + + private async WaitUntilCheckoutOverviewStepIsVisible() { + await this.page.waitForURL(/\/checkout-step-two\.html/, { timeout: 30000 }); + await expect(this.page.locator(this.checkoutSummaryContainer)).toBeVisible(); + } + + public async IsCheckoutStepOneShowingErrorBannerWithMessage(expectedMessage: string) { + const actual = (await this.page.locator(this.errorBanner).textContent())?.trim() ?? ""; + if (actual !== expectedMessage) { + throw new Error(`Expected checkout error "${expectedMessage}" but found "${actual}"`); + } + } + + /** Dispatches by label from feature (e.g. Continue / Finish / Back Home / Cancel). */ + public async ClickCheckoutButtonByLabel(buttonLabel: string) { + switch (buttonLabel) { + case "Continue": + await this.ClickCheckoutContinueButton(); + break; + case "Finish": + await this.ClickCheckoutFinishButton(); + break; + case "Back Home": + await this.ClickBackToProductsButton(); + break; + case "Cancel": + await this.ClickCheckoutCancelButton(); + break; + default: + throw new Error(`Unknown checkout button "${buttonLabel}"`); + } + } + + public async IsCheckoutOverviewShowingPaymentInformationSection() { + await this.WaitUntilCheckoutOverviewStepIsVisible(); + await expect(this.page.locator(this.paymentInfoLabel)).toBeVisible(); + } + + public async IsCheckoutOverviewShowingShippingInformationSection() { + await expect(this.page.locator(this.shippingInfoLabel)).toBeVisible(); + } + + /** Validates `sectionLabel` from the feature and asserts the matching checkout overview block. */ + public async IsCheckoutOverviewSectionPresent(sectionLabel: string) { + switch (sectionLabel) { + case "Payment Information": + await this.IsCheckoutOverviewShowingPaymentInformationSection(); + return; + case "Shipping Information:": + await this.IsCheckoutOverviewShowingShippingInformationSection(); + return; + default: + throw new Error( + `Unknown checkout section "${sectionLabel}". Expected "Payment Information" or "Shipping Information:".` + ); + } + } + + public async IsCheckoutOverviewShowingPriceTotalSection() { + await expect(this.page.locator(this.priceTotalLabel)).toBeVisible(); + } + + public async IsCheckoutOverviewSubtotalTaxAndTotalMatchingExpected(itemTotal: string, tax: string, total: string) { + await this.IsCheckoutOverviewShowingPriceTotalSection(); + await expect(this.page.locator(this.subtotalLabel)).toContainText(itemTotal); + await expect(this.page.locator(this.taxLabel)).toContainText(tax); + await expect(this.page.locator(this.totalLabel)).toContainText(total); + } + + public async IsCheckoutOverviewContainingExactlyTheseProductNames(expectedProductNames: string[]) { + await this.WaitUntilCheckoutOverviewStepIsVisible(); + const expected = expectedProductNames.map((n) => n.trim()).filter((n) => n.length > 0); + const raw = await this.page.locator(this.checkoutSummaryItemName).allTextContents(); + const actual = raw.map((t) => t.trim()).filter((t) => t.length > 0); + + if (actual.length !== expected.length) { + throw new Error(`Expected ${expected.length} product(s) in checkout summary, found ${actual.length}. Actual: ${actual.join(" | ")}`); + } + + const missing = expected.filter((name) => !actual.includes(name)); + + if (missing.length > 0) { + throw new Error(`Missing product(s) in summary: ${missing.join(", ")}. Actual: ${actual.join(" | ")}`); + } + + const extra = actual.filter((name) => !expected.includes(name)); + + if (extra.length > 0) { + throw new Error(`Unexpected product(s) in summary: ${extra.join(", ")}`); + } + } + + public async ClickCheckoutFinishButton() { + await this.page.locator(this.finishButton).click(); + } + + public async IsOrderCompletePageShowingHeaderText(expectedText: string) { + await expect(this.page.locator(this.completeHeader)).toHaveText(expectedText); + } + + public async ClickBackToProductsButton() { + await this.page.locator(this.backHomeButton).click(); + } +} diff --git a/pages/login.page.ts b/pages/login.page.ts index 5a01614..46c7f58 100644 --- a/pages/login.page.ts +++ b/pages/login.page.ts @@ -1,26 +1,61 @@ -import { Page } from "@playwright/test" +import { Page } from "@playwright/test"; export class Login { - private readonly page: Page - private readonly password: string = 'secret_sauce' - private readonly passwordField: string = 'input[id="password"]' - private readonly userNameField: string = 'input[id="user-name"]' - private readonly loginButton: string = 'input[id="login-button"]' - - constructor(page: Page) { - this.page = page; + static readonly validPassword = "secret_sauce"; + static readonly invalidPassword = "wrong_password"; + private readonly page: Page; + private readonly defaultPassword = Login.validPassword; + private readonly userNameField = 'input[id="user-name"]'; + private readonly passwordField = 'input[id="password"]'; + private readonly loginButton = 'input[id="login-button"]'; + private readonly errorMessage = 'h3[data-test="error"]'; + + constructor(page: Page) { + this.page = page; + } + + async IsLoginPageShowingTitle(expectedTitle: string) { + const pageTitle = await this.page.title(); + + if (pageTitle !== expectedTitle) { + throw new Error(`Expected title to be ${expectedTitle} but found ${pageTitle}`); + } + } + + async FillCredentialsAndSubmitLoginForUser(userName: string) { + await this.FillCredentialsAndSubmitLogin(userName, this.defaultPassword); + } + + /** Maps feature keywords (valid, invalid, empty) to real passwords for readable scenarios. */ + ResolvePasswordFromScenarioType(passwordType: string): string { + const t = passwordType.trim().toLowerCase(); + + if (t === "" || t === "empty") { + return ""; + } + + if (t === "valid" || t === "validate") { + return Login.validPassword; } - public async validateTitle(expectedTitle: string) { - const pageTitle = await this.page.title(); - if (pageTitle !== expectedTitle) { - throw new Error(`Expected title to be ${expectedTitle} but found ${pageTitle}`); - } + if (t === "invalid") { + return Login.invalidPassword; } - public async loginAsUser(userName: string) { - await this.page.locator(this.userNameField).fill(userName) - await this.page.locator(this.passwordField).fill(this.password) - await this.page.locator(this.loginButton).click() + throw new Error(`Unknown password type "${passwordType}". Use valid, validate, invalid, empty, or leave blank.`); + } + + async FillCredentialsAndSubmitLogin(userName: string, password: string) { + await this.page.locator(this.userNameField).fill(userName); + await this.page.locator(this.passwordField).fill(password); + await this.page.locator(this.loginButton).click(); + } + + async IsLoginPageShowingErrorMessage(expectedErrorMessage: string) { + const actual = (await this.page.locator(this.errorMessage).textContent())?.trim(); + + if (actual !== expectedErrorMessage) { + throw new Error(`Expected error message to be "${expectedErrorMessage}" but found "${actual}"`); } -} \ No newline at end of file + } +} diff --git a/pages/navigation.page.ts b/pages/navigation.page.ts new file mode 100644 index 0000000..a55ddbc --- /dev/null +++ b/pages/navigation.page.ts @@ -0,0 +1,70 @@ +import { DataTable } from "@cucumber/cucumber"; +import { expect, Page } from "@playwright/test"; + +export class Navigation { + private readonly page: Page; + + private readonly navigationMenuButton: string = "#react-burger-menu-btn"; + private readonly navigationCloseButton: string = "#react-burger-cross-btn"; + private readonly navigationMenu: string = "div.bm-menu"; + private readonly navigationAllItemsLink: string = '[data-test="inventory-sidebar-link"]'; + private readonly navigationAboutLink: string = '[data-test="about-sidebar-link"]'; + private readonly navigationLogoutLink: string = '[data-test="logout-sidebar-link"]'; + private readonly navigationResetLink: string = '[data-test="reset-sidebar-link"]'; + + constructor(page: Page) { + this.page = page; + } + + public async ClickNavigationBarButton() { + await this.page.locator(this.navigationMenuButton).click(); + } + + public async IsNavigationOpen() { + await expect(this.page.locator(this.navigationMenu)).toBeVisible(); + } + + public async IsNavigationOptionsPresent(expectedOptions: string[]) { + await this.IsNavigationOpen(); + + const actualTexts = [ + (await this.page.locator(this.navigationAllItemsLink).textContent())?.trim() ?? "", + (await this.page.locator(this.navigationAboutLink).textContent())?.trim() ?? "", + (await this.page.locator(this.navigationLogoutLink).textContent())?.trim() ?? "", + (await this.page.locator(this.navigationResetLink).textContent())?.trim() ?? "", + ]; + + const expected = expectedOptions.map((t) => t.trim()).filter((t) => t.length > 0); + const missing = expected.filter((opt) => !actualTexts.includes(opt)); + + if (missing.length > 0) { + throw new Error(`Missing navigation option(s): ${missing.join(", ")}. Actual: ${actualTexts.join(" | ")}`); + } + } + + public async IsNavigationOptionsPresentFromTable(table: DataTable) { + const options = table + .rows() + .map((r) => (r[0] ?? "").trim()) + .filter((v) => v.length > 0); + + if (options[0]?.toLowerCase() === "options") { + options.shift(); + } + + await this.IsNavigationOptionsPresent(options); + } + + public async ClickLogoutOption() { + await this.page.locator(this.navigationLogoutLink).click(); + } + + public async ClickNavigationCloseButton() { + await this.page.locator(this.navigationCloseButton).click(); + } + + public async IsNavigationClosed() { + await expect(this.page.locator(this.navigationMenu)).toBeHidden(); + } +} + diff --git a/pages/product-details.page.ts b/pages/product-details.page.ts new file mode 100644 index 0000000..f6a3f2f --- /dev/null +++ b/pages/product-details.page.ts @@ -0,0 +1,56 @@ +import { expect, Page } from "@playwright/test"; + +export class ProductDetails { + private readonly page: Page; + private readonly productDetailsContainer: string = ".inventory_details_container"; + private readonly productDetailsName: string = '[data-test="inventory-item-name"]'; + private readonly productDetailsDescription: string = '[data-test="inventory-item-desc"]'; + private readonly productDetailsPrice: string = '[data-test="inventory-item-price"]'; + private readonly productDetailsBackButton: string = '[data-test="back-to-products"]'; + private readonly addToCartButton: string = '#add-to-cart, [data-test="add-to-cart"]'; + private readonly removeButton: string = '#remove, [data-test="remove"]'; + + constructor(page: Page) { + this.page = page; + } + + public async IsProductDetailsPageOpened() { + await expect(this.page).toHaveURL(/\/inventory-item\.html/); + await expect(this.page.locator(this.productDetailsContainer)).toBeVisible(); + } + + public async IsProductDetailsPageShowingExpectedContent(expectedName: string, expectedDescription: string, expectedPrice: string) { + await this.IsProductDetailsPageOpened(); + await expect(this.page.locator(this.productDetailsName)).toHaveText(expectedName); + await expect(this.page.locator(this.productDetailsDescription)).toHaveText(expectedDescription); + await expect(this.page.locator(this.productDetailsPrice)).toHaveText(expectedPrice); + } + + public async ClickButtonByLabelOnProductDetailsPage(buttonLabel: string) { + const normalized = buttonLabel.trim().toLowerCase(); + + if (normalized.includes("back") && normalized.includes("produc")) { + await this.page.locator(this.productDetailsBackButton).click(); + return; + } + + let buttonSelector = ""; + + if (normalized.includes("add")) { + buttonSelector = this.addToCartButton; + } + else if (normalized.includes("remove")) { + buttonSelector = this.removeButton; + } + else { + throw new Error(`Unsupported details page action "${buttonLabel}". Use "Add to Cart", "Remove", or "Back to products".`); + } + + const button = this.page.locator(buttonSelector).first(); + + if ((await button.count()) === 0) { + throw new Error(`No "${buttonLabel}" button found on product details page`); + } + await button.click(); + } +} diff --git a/pages/product.page.ts b/pages/product.page.ts index 14bedb1..b5bdc26 100644 --- a/pages/product.page.ts +++ b/pages/product.page.ts @@ -1,14 +1,159 @@ -import { Page } from "@playwright/test" +import { expect, 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 page: Page; + private readonly addToCartButton: string = 'button[id="add-to-cart-sauce-labs-backpack"]'; + private readonly sortDropdown: string = '[data-test="product-sort-container"]'; + private readonly productItems: string = ".inventory_item"; + private readonly productItemName: string = ".inventory_item_name"; + private readonly productItemPrice: string = ".inventory_item_price"; + private readonly cartBadge: string = ".shopping_cart_badge"; + private readonly cartLink: string = ".shopping_cart_link"; - constructor(page: Page) { - this.page = page; + constructor(page: Page) { + this.page = page; + } + + public async ClickAddSauceLabsBackpackToCart() { + await this.page.locator(this.addToCartButton).click(); + } + + public async IsProductsPageOpened() { + const title = await this.page.title(); + + if (title !== "Swag Labs") { + throw new Error(`Expected page title "Swag Labs" but found "${title}"`); + } + } + + public async IsProductPageContainingExpectedProductCount(expectedCount: number) { + await expect(this.page.locator(this.productItems)).toHaveCount(expectedCount); + } + + public async ClickProductSortDropdownButton() { + await this.page.locator(this.sortDropdown).click(); + } + + public async IsSortDropdownContainingExpectedOptions(expectedOptions: string[]) { + const options = await this.page.locator(`${this.sortDropdown} option`).allTextContents(); + const actualOptions: string[] = options.map((t) => t.trim()).filter((t) => t.length > 0); + + for (const expected of expectedOptions) { + + if (!actualOptions.includes(expected)) { + throw new Error(`Missing sort option "${expected}". Actual options: ${actualOptions.join(" | ")}`); + } + } + } + + public async SelectSortOptionByLabel(optionLabel: string) { + await this.page.locator(this.sortDropdown).selectOption({ label: optionLabel }); + } + + public async IsProductSortDropdownVisible() { + await expect(this.page.locator(this.sortDropdown)).toBeVisible(); + } + + public async IsSortDropdownShowingSelectedValue(expectedLabel: string) { + const text = await this.page.locator(`${this.sortDropdown} option:checked`).textContent(); + const actualLabel = (text ?? "").trim(); + + if (actualLabel !== expectedLabel) { + throw new Error(`Expected selected sort value "${expectedLabel}" but found "${actualLabel}"`); } + } + + public async IsProductListOrderedAsExpected(expected: { Name: string; Price: string }[]) { + const items = this.page.locator(this.productItems); + const count = await items.count(); - public async addBackPackToCart() { - await this.page.locator(this.addToCart).click() + if (count !== expected.length) { + throw new Error(`Expected ${expected.length} products, found ${count}`); } -} \ No newline at end of file + + for (let index = 0; index < expected.length; index++) { + const product = items.nth(index); + const actualName = ((await product.locator(this.productItemName).textContent()) ?? "").trim(); + const actualPrice = ((await product.locator(this.productItemPrice).textContent()) ?? "").trim(); + + const exp = expected[index]; + + if (actualName !== exp.Name) { + throw new Error(`${index} product: expected name "${exp.Name}" but got "${actualName}"`); + } + + if (actualPrice !== exp.Price) { + throw new Error(`${index} product: expected price "${exp.Price}" but got "${actualPrice}"`); + } + } + } + + public async ClickAddToCartForProductNamed(itemName: string) { + await this.ClickProductCardButtonByLabelForProductNamed("Add to Cart", itemName); + } + + public async ClickProductCardButtonByLabelForProductNamed(buttonLabel: string, itemName: string) { + const normalizedLabel = buttonLabel.trim().toLowerCase(); + const buttonSelector = + normalizedLabel === "add to cart" + ? 'button[id^="add-to-cart-"]' + : normalizedLabel === "remove" + ? 'button[id^="remove-"]' + : ""; + + if (!buttonSelector) { + throw new Error(`Unsupported product action "${buttonLabel}". Use "Add to Cart" or "Remove".`); + } + + const items = this.page.locator(this.productItems); + const count = await items.count(); + + for (let i = 0; i < count; i++) { + const row = items.nth(i); + const actualName = (await row.locator(".inventory_item_name").textContent())?.trim() ?? ""; + + if (actualName !== itemName) continue; + + const button = row.locator(buttonSelector); + + if ((await button.count()) === 0) { + throw new Error(`Found item "${itemName}" but no "${buttonLabel}" button`); + } + + await button.first().click(); + return; + } + + throw new Error(`Inventory item "${itemName}" not found`); + } + + public async ClickShoppingCartBadgeToOpenCart() { + await this.page.locator(this.cartLink).click(); + } + + public async IsShoppingCartBadgeShowingExpectedItemCount(expectedCount: number) { + await expect(this.page.locator(this.cartBadge)).toHaveText(String(expectedCount)); + } + + public async IsShoppingCartBadgeNotVisible() { + await expect(this.page.locator(this.cartBadge)).toHaveCount(0); + } + + public async ClickProductImageForProductNamed(itemName: string) { + const items = this.page.locator(this.productItems); + const count = await items.count(); + + for (let i = 0; i < count; i++) { + const row = items.nth(i); + const actualName = (await row.locator(this.productItemName).textContent())?.trim() ?? ""; + + if (actualName !== itemName) continue; + + await row.locator("img").first().click(); + return; + } + + throw new Error(`Inventory item "${itemName}" not found`); + } + +} diff --git a/playwrightUtilities.ts b/playwrightUtilities.ts index 93244a2..cd6aace 100644 --- a/playwrightUtilities.ts +++ b/playwrightUtilities.ts @@ -6,7 +6,8 @@ const DEFAULT_TIMEOUT = 30000; export const initializeBrowser = async () => { if (!browser) { - browser = await chromium.launch({ headless: false }); + const isCI = process.env.CI === "true"; + browser = await chromium.launch({ headless: isCI }); } }; diff --git a/steps/cart.action.steps.ts b/steps/cart.action.steps.ts new file mode 100644 index 0000000..34dc2d3 --- /dev/null +++ b/steps/cart.action.steps.ts @@ -0,0 +1,13 @@ +import { When } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Cart } from "../pages/cart.page"; + +const cart = () => new Cart(getPage()); + +When("[Cart] I click {string}", async (label: string) => { + await cart().ClickCartPageButtonByLabel(label); +}); + +When("[Cart] I click {string} for {string} item", async (buttonLabel: string, itemName: string) => { + await cart().ClickCartRowButtonByLabelForItemNamed(buttonLabel, itemName); +}); diff --git a/steps/cart.check.steps.ts b/steps/cart.check.steps.ts new file mode 100644 index 0000000..9f27855 --- /dev/null +++ b/steps/cart.check.steps.ts @@ -0,0 +1,22 @@ +import { DataTable, Then } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Cart } from "../pages/cart.page"; + +const cart = () => new Cart(getPage()); + +Then("[Cart] page should be open", async () => { + await cart().IsCartPageOpened(); +}); + +Then("[Cart] {string} should be present", async (itemName: string) => { + await cart().IsCartPageContainingItemNamed(itemName); +}); + +Then("[Cart] {string} should not be present", async (itemName: string) => { + await cart().IsCartPageNotContainingItemNamed(itemName); +}); + +Then("[Cart] the following items should be in the cart:", async (table: DataTable) => { + const names = table.hashes().map((row) => row.Item.trim()); + await cart().IsCartPageContainingExactlyTheseItemNames(names); +}); diff --git a/steps/checkout.action.steps.ts b/steps/checkout.action.steps.ts new file mode 100644 index 0000000..892bb98 --- /dev/null +++ b/steps/checkout.action.steps.ts @@ -0,0 +1,14 @@ +import { DataTable, When } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Checkout } from "../pages/checkout.page"; + +const checkout = () => new Checkout(getPage()); + +When("[Checkout] I fill the checkout page with next value:", async (table: DataTable) => { + const row = table.hashes()[0]; + await checkout().FillCheckoutCustomerFields(row["First Name"] ?? "", row["Last Name"] ?? "", row["Zip code"] ?? ""); +}); + +When("[Checkout] I click on {string} button", async (buttonLabel: string) => { + await checkout().ClickCheckoutButtonByLabel(buttonLabel); +}); diff --git a/steps/checkout.check.steps.ts b/steps/checkout.check.steps.ts new file mode 100644 index 0000000..15b84f4 --- /dev/null +++ b/steps/checkout.check.steps.ts @@ -0,0 +1,31 @@ +import { DataTable, Then } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Checkout } from "../pages/checkout.page"; + +const checkout = () => new Checkout(getPage()); + +Then("[Checkout] page should be open", async () => { + await checkout().IsCheckoutPageOpened(); +}); + +Then("[Checkout] {string} section should be present", async (sectionLabel: string) => { + await checkout().IsCheckoutOverviewSectionPresent(sectionLabel); +}); + +Then("[Checkout] {string} should be present with next values:", async (_label: string, table: DataTable) => { + const row = table.hashes()[0]; + await checkout().IsCheckoutOverviewSubtotalTaxAndTotalMatchingExpected(row["Item total"], row["Tax"], row["Total"]); +}); + +Then("[Checkout] order summary should list products:", async (table: DataTable) => { + const names = table.hashes().map((row) => row.Product.trim()); + await checkout().IsCheckoutOverviewContainingExactlyTheseProductNames(names); +}); + +Then("[Checkout] I should see {string} text", async (expectedText: string) => { + await checkout().IsOrderCompletePageShowingHeaderText(expectedText); +}); + +Then("[Checkout] I should see error message {string}", async (expectedMessage: string) => { + await checkout().IsCheckoutStepOneShowingErrorBannerWithMessage(expectedMessage); +}); diff --git a/steps/common.action.steps.ts b/steps/common.action.steps.ts new file mode 100644 index 0000000..fe54559 --- /dev/null +++ b/steps/common.action.steps.ts @@ -0,0 +1,6 @@ +import { When } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; + +When("I open the {string} page", async (url: string) => { + await getPage().goto(url); +}); diff --git a/steps/common.steps.ts b/steps/common.steps.ts deleted file mode 100644 index c4bee79..0000000 --- a/steps/common.steps.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Given } from "@cucumber/cucumber"; -import { getPage } from "../playwrightUtilities"; - -Given('I open the {string} page', async (url) => { - await getPage().goto(url); - }); \ No newline at end of file diff --git a/steps/login.action.steps.ts b/steps/login.action.steps.ts new file mode 100644 index 0000000..a0d57ec --- /dev/null +++ b/steps/login.action.steps.ts @@ -0,0 +1,13 @@ +import { When } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Login } from "../pages/login.page"; + +const login = () => new Login(getPage()); + +When("[Login page] I will login as {string}", async (userName: string) => { + await login().FillCredentialsAndSubmitLoginForUser(userName); +}); + +When("[Login page] I login with UserName {string} and Password {string}", async (userName: string, passwordType: string) => { + await login().FillCredentialsAndSubmitLogin(userName, login().ResolvePasswordFromScenarioType(passwordType)); +}); diff --git a/steps/login.check.steps.ts b/steps/login.check.steps.ts new file mode 100644 index 0000000..379ada2 --- /dev/null +++ b/steps/login.check.steps.ts @@ -0,0 +1,13 @@ +import { Then } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Login } from "../pages/login.page"; + +const login = () => new Login(getPage()); + +Then("[Login page] {string} title should be present", async (expectedTitle: string) => { + await login().IsLoginPageShowingTitle(expectedTitle); +}); + +Then("[Login page] I should see the error message {string}", async (errorMessage: string) => { + await login().IsLoginPageShowingErrorMessage(errorMessage); +}); diff --git a/steps/login.steps.ts b/steps/login.steps.ts deleted file mode 100644 index c2aa0d8..0000000 --- a/steps/login.steps.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Then } from '@cucumber/cucumber'; -import { getPage } from '../playwrightUtilities'; -import { Login } from '../pages/login.page'; - -Then('I should see the title {string}', async (expectedTitle) => { - await new Login(getPage()).validateTitle(expectedTitle); -}); - -Then('I will login as {string}', async (userName) => { - await new Login(getPage()).loginAsUser(userName); -}); \ No newline at end of file diff --git a/steps/navigation.action.steps.ts b/steps/navigation.action.steps.ts new file mode 100644 index 0000000..c69015f --- /dev/null +++ b/steps/navigation.action.steps.ts @@ -0,0 +1,18 @@ +import { When } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Navigation } from "../pages/navigation.page"; + +const navigation = () => new Navigation(getPage()); + +When("[Products page] I click navigation bar button", async () => { + await navigation().ClickNavigationBarButton(); +}); + +When("[Navigation] I click close", async () => { + await navigation().ClickNavigationCloseButton(); +}); + +When("[Navigation] I click logout", async () => { + await navigation().ClickLogoutOption(); +}); + diff --git a/steps/navigation.check.steps.ts b/steps/navigation.check.steps.ts new file mode 100644 index 0000000..95b6204 --- /dev/null +++ b/steps/navigation.check.steps.ts @@ -0,0 +1,18 @@ +import { DataTable, Then } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Navigation } from "../pages/navigation.page"; + +const navigation = () => new Navigation(getPage()); + +Then("[Navigation] should be open", async () => { + await navigation().IsNavigationOpen(); +}); + +Then("[Navigation] options should be present with next options:", async (table: DataTable) => { + await navigation().IsNavigationOptionsPresentFromTable(table); +}); + +Then("[Navigation] should be closed", async () => { + await navigation().IsNavigationClosed(); +}); + diff --git a/steps/product-details.action.steps.ts b/steps/product-details.action.steps.ts new file mode 100644 index 0000000..24522b9 --- /dev/null +++ b/steps/product-details.action.steps.ts @@ -0,0 +1,9 @@ +import { When } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { ProductDetails } from "../pages/product-details.page"; + +const productDetails = () => new ProductDetails(getPage()); + +When("[Product details page] I click {string} button", async (buttonLabel: string) => { + await productDetails().ClickButtonByLabelOnProductDetailsPage(buttonLabel); +}); diff --git a/steps/product-details.check.steps.ts b/steps/product-details.check.steps.ts new file mode 100644 index 0000000..d9c7125 --- /dev/null +++ b/steps/product-details.check.steps.ts @@ -0,0 +1,14 @@ +import { DataTable, Then } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { ProductDetails } from "../pages/product-details.page"; + +const productDetails = () => new ProductDetails(getPage()); + +Then("[Product details page] should be open", async () => { + await productDetails().IsProductDetailsPageOpened(); +}); + +Then("[Product details page] should show expected content:", async (table: DataTable) => { + const row = table.hashes()[0]; + await productDetails().IsProductDetailsPageShowingExpectedContent(row.Name, row.Description, row.Price); +}); diff --git a/steps/product.action.steps.ts b/steps/product.action.steps.ts new file mode 100644 index 0000000..2b0c63f --- /dev/null +++ b/steps/product.action.steps.ts @@ -0,0 +1,29 @@ +import { When } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Product } from "../pages/product.page"; + +const product = () => new Product(getPage()); + +When("I will add the backpack to the cart", async () => { + await product().ClickAddSauceLabsBackpackToCart(); +}); + +When("[Products page] I click on 'Sort' dropdown button", async () => { + await product().ClickProductSortDropdownButton(); +}); + +When("[Products page] I click on {string} option", async (optionLabel: string) => { + await product().SelectSortOptionByLabel(optionLabel); +}); + +When("[Products page] I click {string} for {string} item", async (buttonLabel: string, itemName: string) => { + await product().ClickProductCardButtonByLabelForProductNamed(buttonLabel, itemName); +}); + +When("[Products page] I click the cart icon", async () => { + await product().ClickShoppingCartBadgeToOpenCart(); +}); + +When("[Products page] I click image for {string} item", async (itemName: string) => { + await product().ClickProductImageForProductNamed(itemName); +}); diff --git a/steps/product.check.steps.ts b/steps/product.check.steps.ts new file mode 100644 index 0000000..cc2a5cb --- /dev/null +++ b/steps/product.check.steps.ts @@ -0,0 +1,44 @@ +import { DataTable, Then } from "@cucumber/cucumber"; +import { getPage } from "../playwrightUtilities"; +import { Product } from "../pages/product.page"; + +const product = () => new Product(getPage()); + +Then("[Products page] Page should be opened", async () => { + await product().IsProductsPageOpened(); +}); + +Then("[Products page] '{int}' Products should be present on the page", async (n: number) => { + await product().IsProductPageContainingExpectedProductCount(n); +}); + +Then("[Products page] {string} dropdown menu should be present with next options:", async (_sortLabel: string, table: DataTable) => { + const options = table.rows().slice(1).map((r) => r[0]); + await product().IsSortDropdownContainingExpectedOptions(options); +}); + +Then("[Products page] {string} dropdown menu should be closed", async (_sortLabel: string) => { + await product().IsProductSortDropdownVisible(); +}); + +Then("[Products page] {string} value should be present in the 'Sort' dropdown field", async (expected: string) => { + await product().IsSortDropdownShowingSelectedValue(expected); +}); + +Then("[Products page] Products should be sorted with the next values:", async (table: DataTable) => { + const rows = table.hashes().map((row) => ({ Name: row.Name, Price: row.Price })); + await product().IsProductListOrderedAsExpected(rows); +}); + +Then("[Products page] I should see {string} in the cart badge", async (expectedCountText: string) => { + const expectedCount = Number.parseInt(expectedCountText, 10); + await product().IsShoppingCartBadgeShowingExpectedItemCount(expectedCount); +}); + +Then("[Products page] page should be open", async () => { + await product().IsProductsPageOpened(); +}); + +Then("[Products page] cart badge should be removed", async () => { + await product().IsShoppingCartBadgeNotVisible(); +}); diff --git a/steps/product.steps.ts b/steps/product.steps.ts deleted file mode 100644 index bb52fb9..0000000 --- a/steps/product.steps.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Then } from '@cucumber/cucumber'; -import { getPage } from '../playwrightUtilities'; -import { Product } from '../pages/product.page'; - -Then('I will add the backpack to the cart', async () => { - await new Product(getPage()).addBackPackToCart(); -}); \ No newline at end of file