├── tests ├── sauceLabsDemo.spec.ts ├── example.spec.ts ├── e2e │ ├── login.spec.js │ ├── checkout.spec.js │ └── payment.spec.js ├── mobile │ └── mobile.spec.js ├── fixtures │ └── authFixtures.js ├── api │ └── users.api.spec.js └── tests-examples │ └── demo-todo-app.spec.ts ├── .gitignore ├── JS └── example.js ├── e2e └── example.spec.ts ├── .env ├── jsconfig.json ├── package.json ├── playwright.config.ts ├── .github └── workflows │ ├── performance.yml │ ├── security.yml │ └── playwright.yml ├── config ├── dev.env.js ├── qa.env.js ├── staging.env.js └── prod.env.js ├── data ├── products.csv ├── users.json └── credentials.js ├── src └── utils │ ├── apiHelper.js │ ├── dbHelper.js │ └── testDataGenerator.js ├── playwright.config.js ├── pages ├── LoginPage.js ├── HomePage.js ├── CartPage.js └── CheckoutPage.js ├── README.md └── Jenkinsfile /tests/sauceLabsDemo.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from '@playwright/test'; 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node modules 2 | node_modules/ 3 | 4 | # Playwright reports and cache 5 | test-results/ 6 | playwright-report/ 7 | blob-report/ 8 | playwright/.cache/ 9 | 10 | # Environment and secrets 11 | .env 12 | .env.* 13 | *.env 14 | 15 | # Logs 16 | *.log 17 | logs/ 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # OS files 23 | .DS_Store 24 | Thumbs.db 25 | 26 | # IDE files 27 | .vscode/ 28 | .idea/ 29 | 30 | # Coverage reports 31 | coverage/ 32 | *.lcov 33 | 34 | # Misc 35 | reports/ 36 | *.json 37 | dist/ 38 | build/ 39 | tmp/ 40 | temp/ 41 | screenshots/ -------------------------------------------------------------------------------- /JS/example.js: -------------------------------------------------------------------------------- 1 | // Run this file to see if JS is running || Hit on VS Code => Ctrl + Alt + N 2 | console.log("Hello, World!"); 3 | // Using `let` for a variable that can be reassigned 4 | let message = "This is a string."; 5 | console.log(message); // Output: This is a string. 6 | 7 | message = "This is a new string."; 8 | console.log(message); // Output: This is a new string. 9 | 10 | // Using `const` for a constant variable 11 | const price = 10.99; 12 | console.log(price); // Output: 10.99 13 | 14 | // Using `var` (older keyword, generally less preferred) 15 | var is_active = true; 16 | console.log(is_active); // Output: true -------------------------------------------------------------------------------- /e2e/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('has title', async ({ page }) => { 4 | await page.goto('https://playwright.dev/'); 5 | 6 | // Expect a title "to contain" a substring. 7 | await expect(page).toHaveTitle(/Playwright/); 8 | }); 9 | 10 | test('get started link', async ({ page }) => { 11 | await page.goto('https://playwright.dev/'); 12 | 13 | // Click the get started link. 14 | await page.getByRole('link', { name: 'Get started' }).click(); 15 | 16 | // Expects page to have a heading with the name of Installation. 17 | await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); 18 | }); 19 | -------------------------------------------------------------------------------- /tests/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('has title', async ({ page }) => { 4 | await page.goto('https://playwright.dev/'); 5 | 6 | // Expect a title "to contain" a substring. 7 | await expect(page).toHaveTitle(/Playwright/); 8 | }); 9 | 10 | test('get started link', async ({ page }) => { 11 | await page.goto('https://playwright.dev/'); 12 | 13 | // Click the get started link. 14 | await page.getByRole('link', { name: 'Get started' }).click(); 15 | 16 | // Expects page to have a heading with the name of Installation. 17 | await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); 18 | }); 19 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Environment variables for test configuration 2 | BASE_URL=https://www.saucedemo.com 3 | API_BASE_URL=https://api.saucedemo.com 4 | TIMEOUT=30000 5 | 6 | # Database configuration 7 | DB_HOST=localhost 8 | DB_PORT=5432 9 | DB_NAME=testdb 10 | DB_USER=testuser 11 | DB_PASSWORD=testpass 12 | 13 | # Authentication 14 | ADMIN_USERNAME=standard_user 15 | ADMIN_PASSWORD=secret_sauce 16 | TEST_USER_EMAIL=test@example.com 17 | TEST_USER_PASSWORD=testpass123 18 | 19 | # API Keys (for external services) 20 | API_KEY=your_api_key_here 21 | SECRET_KEY=your_secret_key_here 22 | 23 | # Browser configuration 24 | HEADLESS=false 25 | BROWSER=chromium 26 | VIEWPORT_WIDTH=1280 27 | VIEWPORT_HEIGHT=720 28 | 29 | # Test environment 30 | TEST_ENV=dev 31 | DEBUG=false 32 | SLOW_MO=0 33 | 34 | # Reporting 35 | ALLURE_RESULTS_DIR=reports/allure-results 36 | HTML_REPORT_DIR=reports/html-report 37 | 38 | # Screenshots and videos 39 | SCREENSHOT_MODE=only-on-failure 40 | VIDEO_MODE=retain-on-failure 41 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "commonjs", 5 | "lib": ["ES2022", "DOM"], 6 | "allowJs": true, 7 | "checkJs": false, 8 | "outDir": "./dist", 9 | "rootDir": "./", 10 | "strict": false, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "allowSyntheticDefaultImports": true, 17 | "baseUrl": "./", 18 | "paths": { 19 | "@pages/*": ["pages/*"], 20 | "@utils/*": ["src/utils/*"], 21 | "@config/*": ["config/*"], 22 | "@data/*": ["data/*"], 23 | "@fixtures/*": ["tests/fixtures/*"] 24 | } 25 | }, 26 | "include": [ 27 | "tests/**/*", 28 | "pages/**/*", 29 | "src/**/*", 30 | "config/**/*", 31 | "data/**/*", 32 | "playwright.config.js" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "dist", 37 | "reports", 38 | "screenshots" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playwrightpractice01", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "description": "Playwright Test Automation Framework in JavaScript", 6 | "scripts": { 7 | "test": "npx playwright test", 8 | "test:headed": "npx playwright test --headed", 9 | "test:debug": "npx playwright test --debug", 10 | "test:e2e": "npx playwright test tests/e2e/", 11 | "test:api": "npx playwright test tests/api/", 12 | "test:mobile": "npx playwright test tests/mobile/", 13 | "test:smoke": "npx playwright test --grep @smoke", 14 | "test:regression": "npx playwright test --grep @regression", 15 | "test:chromium": "npx playwright test --project=chromium", 16 | "test:firefox": "npx playwright test --project=firefox", 17 | "test:webkit": "npx playwright test --project=webkit", 18 | "test:dev": "TEST_ENV=dev npx playwright test", 19 | "test:qa": "TEST_ENV=qa npx playwright test", 20 | "test:staging": "TEST_ENV=staging npx playwright test", 21 | "report": "npx playwright show-report", 22 | "install:browsers": "npx playwright install --with-deps", 23 | "codegen": "npx playwright codegen", 24 | "trace": "npx playwright show-trace" 25 | }, 26 | "keywords": [ 27 | "playwright", 28 | "testing", 29 | "automation", 30 | "e2e", 31 | "javascript", 32 | "qa" 33 | ], 34 | "author": "QA Team", 35 | "license": "MIT", 36 | "devDependencies": { 37 | "@faker-js/faker": "^8.4.1", 38 | "@playwright/test": "^1.55.0", 39 | "@types/node": "^24.3.0", 40 | "dotenv": "^17.2.2", 41 | "pg": "^8.11.0" 42 | }, 43 | "dependencies": { 44 | "allure-playwright": "^2.10.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // import dotenv from 'dotenv'; 8 | // import path from 'path'; 9 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 10 | 11 | /** 12 | * See https://playwright.dev/docs/test-configuration. 13 | */ 14 | export default defineConfig({ 15 | testDir: './tests', 16 | /* Run tests in files in parallel */ 17 | fullyParallel: true, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: !!process.env.CI, 20 | /* Retry on CI only */ 21 | retries: process.env.CI ? 2 : 0, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: process.env.CI ? 1 : undefined, 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: 'html', 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | /* Base URL to use in actions like `await page.goto('/')`. */ 29 | // baseURL: 'http://localhost:3000', 30 | 31 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 32 | trace: 'on-first-retry', 33 | }, 34 | 35 | /* Configure projects for major browsers */ 36 | projects: [ 37 | { 38 | name: 'chromium', 39 | use: { ...devices['Desktop Chrome'] }, 40 | }, 41 | 42 | { 43 | name: 'firefox', 44 | use: { ...devices['Desktop Firefox'] }, 45 | }, 46 | 47 | { 48 | name: 'webkit', 49 | use: { ...devices['Desktop Safari'] }, 50 | }, 51 | 52 | /* Test against mobile viewports. */ 53 | // { 54 | // name: 'Mobile Chrome', 55 | // use: { ...devices['Pixel 5'] }, 56 | // }, 57 | // { 58 | // name: 'Mobile Safari', 59 | // use: { ...devices['iPhone 12'] }, 60 | // }, 61 | 62 | /* Test against branded browsers. */ 63 | // { 64 | // name: 'Microsoft Edge', 65 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 66 | // }, 67 | // { 68 | // name: 'Google Chrome', 69 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 70 | // }, 71 | ], 72 | 73 | /* Run your local dev server before starting the tests */ 74 | // webServer: { 75 | // command: 'npm run start', 76 | // url: 'http://localhost:3000', 77 | // reuseExistingServer: !process.env.CI, 78 | // }, 79 | }); 80 | -------------------------------------------------------------------------------- /.github/workflows/performance.yml: -------------------------------------------------------------------------------- 1 | name: Performance Tests 2 | 3 | on: 4 | #schedule: 5 | # Run performance tests weekly on Sundays at 3 AM UTC 6 | #- cron: '0 3 * * 0' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | performance: 11 | timeout-minutes: 120 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: '18' 20 | cache: 'npm' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Install Playwright Browsers 26 | run: npx playwright install --with-deps 27 | 28 | - name: Run Performance Tests 29 | run: npx playwright test --grep "@performance" 30 | env: 31 | CI: true 32 | HEADLESS: true 33 | 34 | - name: Generate Performance Report 35 | run: | 36 | echo "Performance test completed" 37 | # Add custom performance reporting logic here 38 | 39 | - name: Upload Performance Results 40 | uses: actions/upload-artifact@v4 41 | if: always() 42 | with: 43 | name: performance-results 44 | path: reports/ 45 | retention-days: 90 46 | 47 | - name: Notify on Performance Issues 48 | if: failure() 49 | run: | 50 | if [ -n "${{ secrets.SLACK_WEBHOOK }}" ]; then 51 | curl -X POST -H 'Content-type: application/json' \ 52 | --data '{ 53 | "channel": "#performance-alerts", 54 | "text": "❌ Performance tests failed in ${{ github.repository }}", 55 | "attachments": [ 56 | { 57 | "color": "danger", 58 | "fields": [ 59 | { 60 | "title": "Branch", 61 | "value": "${{ github.ref_name }}", 62 | "short": true 63 | }, 64 | { 65 | "title": "Commit", 66 | "value": "${{ github.sha }}", 67 | "short": true 68 | }, 69 | { 70 | "title": "Build URL", 71 | "value": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", 72 | "short": false 73 | } 74 | ] 75 | } 76 | ] 77 | }' \ 78 | "${{ secrets.SLACK_WEBHOOK }}" 79 | else 80 | echo "SLACK_WEBHOOK secret not configured, skipping notification" 81 | fi 82 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Development environment configuration 3 | */ 4 | const devConfig = { 5 | // Base URLs 6 | baseURL: 'https://dev.saucedemo.com', 7 | apiBaseURL: 'https://dev-api.saucedemo.com', 8 | 9 | // Authentication 10 | credentials: { 11 | standard_user: { 12 | username: 'standard_user', 13 | password: 'secret_sauce' 14 | }, 15 | admin_user: { 16 | username: 'admin_user', 17 | password: 'admin_pass' 18 | } 19 | }, 20 | 21 | // Database configuration 22 | database: { 23 | host: 'dev-db.example.com', 24 | port: 5432, 25 | name: 'saucedemo_dev', 26 | user: 'dev_user', 27 | password: 'dev_password', 28 | ssl: false, 29 | pool: { 30 | min: 2, 31 | max: 10, 32 | idleTimeoutMillis: 30000, 33 | connectionTimeoutMillis: 2000 34 | } 35 | }, 36 | 37 | // Browser configuration 38 | browser: { 39 | headless: false, 40 | slowMo: 100, 41 | viewport: { 42 | width: 1280, 43 | height: 720 44 | }, 45 | timeout: 30000, 46 | navigationTimeout: 30000 47 | }, 48 | 49 | // Test configuration 50 | test: { 51 | timeout: 30000, 52 | expect: { 53 | timeout: 5000 54 | }, 55 | retries: 1, 56 | workers: 2, 57 | fullyParallel: false 58 | }, 59 | 60 | // Reporting 61 | reporting: { 62 | htmlReportDir: 'reports/html-report', 63 | allureResultsDir: 'reports/allure-results', 64 | screenshotMode: 'only-on-failure', 65 | videoMode: 'retain-on-failure', 66 | trace: 'retain-on-failure' 67 | }, 68 | 69 | // External services 70 | services: { 71 | sauceLabs: { 72 | enabled: false, 73 | username: process.env.SAUCE_USERNAME || '', 74 | accessKey: process.env.SAUCE_ACCESS_KEY || '' 75 | }, 76 | browserStack: { 77 | enabled: false, 78 | username: process.env.BROWSERSTACK_USERNAME || '', 79 | accessKey: process.env.BROWSERSTACK_ACCESS_KEY || '' 80 | } 81 | }, 82 | 83 | // Logging 84 | logging: { 85 | level: 'debug', 86 | console: true, 87 | file: { 88 | enabled: true, 89 | path: 'logs/dev.log' 90 | } 91 | }, 92 | 93 | // Feature flags 94 | features: { 95 | enableNewCheckoutFlow: true, 96 | enablePerformanceMonitoring: true, 97 | enableAPITesting: true 98 | }, 99 | 100 | // Third-party integrations 101 | integrations: { 102 | slack: { 103 | enabled: false, 104 | webhook: process.env.SLACK_WEBHOOK || '' 105 | }, 106 | jira: { 107 | enabled: false, 108 | baseUrl: process.env.JIRA_BASE_URL || '', 109 | token: process.env.JIRA_TOKEN || '' 110 | } 111 | } 112 | }; 113 | 114 | module.exports = devConfig; 115 | -------------------------------------------------------------------------------- /data/products.csv: -------------------------------------------------------------------------------- 1 | id,name,description,price,category,brand,sku,stock,image 2 | 1,"Sauce Labs Backpack","carry.allTheThings() with the sleek, streamlined Sly Pack that melds uncompromising style with unequaled laptop and tablet protection.",29.99,"Accessories","Sauce Labs","sauce-labs-backpack",25,"/static/media/sauce-backpack-1200x1500.0a0b85a3.jpg" 3 | 2,"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,"Accessories","Sauce Labs","sauce-labs-bike-light",15,"/static/media/bike-light-1200x1500.37c843b0.jpg" 4 | 3,"Sauce Labs Bolt T-Shirt","Get your testing superhero on with the Sauce Labs bolt T-shirt. From American Apparel, 100% ringspun combed cotton, heather grey with red bolt.",15.99,"Clothing","American Apparel","sauce-labs-bolt-t-shirt",30,"/static/media/bolt-shirt-1200x1500.c2599ac5.jpg" 5 | 4,"Sauce Labs Fleece Jacket","It's not every day that you come across a midweight quarter-zip fleece jacket capable of handling everything from a relaxing day outdoors to a busy day at the office.",49.99,"Clothing","Sauce Labs","sauce-labs-fleece-jacket",10,"/static/media/sauce-pullover-1200x1500.51d7ffaf.jpg" 6 | 5,"Sauce Labs Onesie","Rib snap infant onesie for the junior automation engineer in development. Reinforced 3-snap bottom closure, two-needle hemmed sleeves and bottom won't unravel.",7.99,"Clothing","Sauce Labs","sauce-labs-onesie",20,"/static/media/red-onesie-1200x1500.2ec615b2.jpg" 7 | 6,"Test.allTheThings() T-Shirt (Red)","This classic Sauce Labs t-shirt is perfect to wear when cozying up to your keyboard to automate a few tests. Super-soft and comfy ringspun combed cotton blend slim fit you'll want to wear every day.",15.99,"Clothing","Sauce Labs","test-allthethings-t-shirt-red",18,"/static/media/red-tatt-1200x1500.30dadef4.jpg" 8 | 7,"Sauce Labs Sticker","Show your Sauce Labs pride with this high-quality vinyl sticker pack. Includes 4 stickers in various sizes.",2.99,"Accessories","Sauce Labs","sauce-labs-sticker",50,"/static/media/sauce-sticker-1200x1500.46c7c7cd.jpg" 9 | 8,"Sauce Labs Mug","Ceramic coffee mug with Sauce Labs logo. 11 oz capacity, microwave and dishwasher safe.",12.99,"Kitchen","Sauce Labs","sauce-labs-mug",8,"/static/media/sauce-mug-1200x1500.c79c09e7.jpg" 10 | 9,"Test Automation T-Shirt","Premium quality t-shirt for test automation enthusiasts. 100% cotton, pre-shrunk.",19.99,"Clothing","Test Co","test-automation-tshirt",22,"/static/media/automation-shirt-1200x1500.a1c2b3d4.jpg" 11 | 10,"QA Engineer Hoodie","Comfortable hoodie for QA engineers. Soft fleece interior, adjustable hood.",39.99,"Clothing","QA Gear","qa-engineer-hoodie",12,"/static/media/qa-hoodie-1200x1500.e5f6g7h8.jpg" 12 | 11,"Bug Report Notebook","Professional notebook for documenting bugs and test cases. 200 pages, ruled.",8.99,"Office","Bug Tracker","bug-report-notebook",35,"/static/media/bug-notebook-1200x1500.i9j0k1l2.jpg" 13 | 12,"Selenium WebDriver Book","Comprehensive guide to Selenium WebDriver automation. 450 pages.",34.99,"Books","Tech Books","selenium-webdriver-book",5,"/static/media/selenium-book-1200x1500.m3n4o5p6.jpg" 14 | -------------------------------------------------------------------------------- /tests/e2e/login.spec.js: -------------------------------------------------------------------------------- 1 | const { test, expect } = require('@playwright/test'); 2 | const LoginPage = require('../../pages/LoginPage'); 3 | const HomePage = require('../../pages/HomePage'); 4 | 5 | test.describe('Login Tests', () => { 6 | let loginPage; 7 | let homePage; 8 | 9 | test.beforeEach(async ({ page }) => { 10 | loginPage = new LoginPage(page); 11 | homePage = new HomePage(page); 12 | await page.goto('/'); 13 | }); 14 | 15 | test('should login with valid credentials', async ({ page }) => { 16 | // Test data 17 | const username = 'standard_user'; 18 | const password = 'secret_sauce'; 19 | 20 | // Perform login 21 | await loginPage.login(username, password); 22 | 23 | // Verify successful login 24 | await expect(homePage.productsHeader).toBeVisible(); 25 | await expect(page.url()).toContain('/inventory.html'); 26 | }); 27 | 28 | test('should show error with invalid credentials', async ({ page }) => { 29 | // Test data 30 | const username = 'invalid_user'; 31 | const password = 'wrong_password'; 32 | 33 | // Perform login with invalid credentials 34 | await loginPage.login(username, password); 35 | 36 | // Verify error message 37 | await expect(loginPage.errorMessage).toBeVisible(); 38 | await expect(loginPage.errorMessage).toContainText('Username and password do not match'); 39 | }); 40 | 41 | test('should show error when username is empty', async ({ page }) => { 42 | // Test data 43 | const username = ''; 44 | const password = 'secret_sauce'; 45 | 46 | // Perform login with empty username 47 | await loginPage.login(username, password); 48 | 49 | // Verify error message 50 | await expect(loginPage.errorMessage).toBeVisible(); 51 | await expect(loginPage.errorMessage).toContainText('Username is required'); 52 | }); 53 | 54 | test('should show error when password is empty', async ({ page }) => { 55 | // Test data 56 | const username = 'standard_user'; 57 | const password = ''; 58 | 59 | // Perform login with empty password 60 | await loginPage.login(username, password); 61 | 62 | // Verify error message 63 | await expect(loginPage.errorMessage).toBeVisible(); 64 | await expect(loginPage.errorMessage).toContainText('Password is required'); 65 | }); 66 | 67 | test('should handle locked out user', async ({ page }) => { 68 | // Test data 69 | const username = 'locked_out_user'; 70 | const password = 'secret_sauce'; 71 | 72 | // Perform login with locked out user 73 | await loginPage.login(username, password); 74 | 75 | // Verify error message 76 | await expect(loginPage.errorMessage).toBeVisible(); 77 | await expect(loginPage.errorMessage).toContainText('Sorry, this user has been locked out'); 78 | }); 79 | 80 | test('should clear error message when clicking X button', async ({ page }) => { 81 | // First, trigger an error 82 | await loginPage.login('invalid_user', 'wrong_password'); 83 | await expect(loginPage.errorMessage).toBeVisible(); 84 | 85 | // Click the close button 86 | await loginPage.closeErrorMessage(); 87 | 88 | // Verify error message is hidden 89 | await expect(loginPage.errorMessage).not.toBeVisible(); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security Tests 2 | 3 | on: 4 | #schedule: 5 | # Run security tests weekly on Saturdays at 4 AM UTC 6 | #- cron: '0 4 * * 6' 7 | workflow_dispatch: 8 | inputs: 9 | target_url: 10 | description: 'Target URL for security testing' 11 | required: false 12 | default: 'https://www.saucedemo.com' 13 | 14 | jobs: 15 | security-scan: 16 | timeout-minutes: 90 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: '18' 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Install Playwright Browsers 31 | run: npx playwright install --with-deps 32 | 33 | - name: Run Security Tests 34 | run: npx playwright test --grep "@security" 35 | env: 36 | CI: true 37 | HEADLESS: true 38 | TARGET_URL: ${{ github.event.inputs.target_url || 'https://www.saucedemo.com' }} 39 | 40 | - name: OWASP ZAP Scan 41 | uses: zaproxy/action-full-scan@v0.8.0 42 | with: 43 | target: ${{ github.event.inputs.target_url || 'https://www.saucedemo.com' }} 44 | rules_file_name: '.zap/rules.tsv' 45 | cmd_options: '-a' 46 | 47 | - name: Upload Security Results 48 | uses: actions/upload-artifact@v4 49 | if: always() 50 | with: 51 | name: security-scan-results 52 | path: | 53 | reports/ 54 | report_html.html 55 | report_md.md 56 | retention-days: 30 57 | 58 | - name: Notify Security Team 59 | if: failure() 60 | run: | 61 | if [ -n "${{ secrets.SLACK_WEBHOOK }}" ]; then 62 | curl -X POST -H 'Content-type: application/json' \ 63 | --data '{ 64 | "channel": "#security-alerts", 65 | "text": "🚨 Security scan detected vulnerabilities in ${{ github.repository }}", 66 | "attachments": [ 67 | { 68 | "color": "danger", 69 | "fields": [ 70 | { 71 | "title": "Branch", 72 | "value": "${{ github.ref_name }}", 73 | "short": true 74 | }, 75 | { 76 | "title": "Target URL", 77 | "value": "${{ github.event.inputs.target_url || 'https://www.saucedemo.com' }}", 78 | "short": true 79 | }, 80 | { 81 | "title": "Build URL", 82 | "value": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", 83 | "short": false 84 | }, 85 | { 86 | "title": "Action", 87 | "value": "Please review the security scan results immediately", 88 | "short": false 89 | } 90 | ] 91 | } 92 | ] 93 | }' \ 94 | "${{ secrets.SLACK_WEBHOOK }}" 95 | else 96 | echo "SLACK_WEBHOOK secret not configured, skipping notification" 97 | fi 98 | -------------------------------------------------------------------------------- /config/qa.env.js: -------------------------------------------------------------------------------- 1 | /** 2 | * QA environment configuration 3 | */ 4 | const qaConfig = { 5 | // Base URLs 6 | baseURL: 'https://qa.saucedemo.com', 7 | apiBaseURL: 'https://qa-api.saucedemo.com', 8 | 9 | // Authentication 10 | credentials: { 11 | standard_user: { 12 | username: 'standard_user', 13 | password: 'secret_sauce' 14 | }, 15 | admin_user: { 16 | username: 'qa_admin', 17 | password: 'qa_admin_pass' 18 | }, 19 | test_user: { 20 | username: 'qa_test_user', 21 | password: 'qa_test_pass' 22 | } 23 | }, 24 | 25 | // Database configuration 26 | database: { 27 | host: 'qa-db.example.com', 28 | port: 5432, 29 | name: 'saucedemo_qa', 30 | user: 'qa_user', 31 | password: 'qa_password', 32 | ssl: true, 33 | pool: { 34 | min: 2, 35 | max: 15, 36 | idleTimeoutMillis: 30000, 37 | connectionTimeoutMillis: 2000 38 | } 39 | }, 40 | 41 | // Browser configuration 42 | browser: { 43 | headless: true, 44 | slowMo: 0, 45 | viewport: { 46 | width: 1920, 47 | height: 1080 48 | }, 49 | timeout: 45000, 50 | navigationTimeout: 45000 51 | }, 52 | 53 | // Test configuration 54 | test: { 55 | timeout: 45000, 56 | expect: { 57 | timeout: 10000 58 | }, 59 | retries: 2, 60 | workers: 4, 61 | fullyParallel: true 62 | }, 63 | 64 | // Reporting 65 | reporting: { 66 | htmlReportDir: 'reports/html-report', 67 | allureResultsDir: 'reports/allure-results', 68 | screenshotMode: 'only-on-failure', 69 | videoMode: 'retain-on-failure', 70 | trace: 'retain-on-failure' 71 | }, 72 | 73 | // External services 74 | services: { 75 | sauceLabs: { 76 | enabled: true, 77 | username: process.env.SAUCE_USERNAME || '', 78 | accessKey: process.env.SAUCE_ACCESS_KEY || '', 79 | buildName: `QA Build ${new Date().toISOString()}` 80 | }, 81 | browserStack: { 82 | enabled: false, 83 | username: process.env.BROWSERSTACK_USERNAME || '', 84 | accessKey: process.env.BROWSERSTACK_ACCESS_KEY || '' 85 | } 86 | }, 87 | 88 | // Logging 89 | logging: { 90 | level: 'info', 91 | console: true, 92 | file: { 93 | enabled: true, 94 | path: 'logs/qa.log' 95 | } 96 | }, 97 | 98 | // Feature flags 99 | features: { 100 | enableNewCheckoutFlow: true, 101 | enablePerformanceMonitoring: true, 102 | enableAPITesting: true, 103 | enableE2ETesting: true 104 | }, 105 | 106 | // Third-party integrations 107 | integrations: { 108 | slack: { 109 | enabled: true, 110 | webhook: process.env.SLACK_WEBHOOK || '', 111 | channels: { 112 | failures: '#qa-failures', 113 | reports: '#qa-reports' 114 | } 115 | }, 116 | jira: { 117 | enabled: true, 118 | baseUrl: process.env.JIRA_BASE_URL || '', 119 | token: process.env.JIRA_TOKEN || '', 120 | projectKey: 'QA' 121 | } 122 | }, 123 | 124 | // QA specific settings 125 | qa: { 126 | smokeTestsOnly: false, 127 | includeLongRunningTests: true, 128 | maxTestDuration: 300000, // 5 minutes 129 | parallelSuites: true, 130 | dataReset: { 131 | enabled: true, 132 | beforeEachSuite: false, 133 | afterEachSuite: true 134 | } 135 | } 136 | }; 137 | 138 | module.exports = qaConfig; 139 | -------------------------------------------------------------------------------- /src/utils/apiHelper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API Helper utilities for making HTTP requests 3 | */ 4 | class ApiHelper { 5 | constructor(baseURL = process.env.API_BASE_URL) { 6 | this.baseURL = baseURL; 7 | this.defaultHeaders = { 8 | 'Content-Type': 'application/json', 9 | 'Accept': 'application/json' 10 | }; 11 | } 12 | 13 | /** 14 | * Make a GET request 15 | * @param {string} endpoint - API endpoint 16 | * @param {Object} headers - Additional headers 17 | * @returns {Promise} Response data 18 | */ 19 | async get(endpoint, headers = {}) { 20 | const response = await fetch(`${this.baseURL}${endpoint}`, { 21 | method: 'GET', 22 | headers: { ...this.defaultHeaders, ...headers } 23 | }); 24 | 25 | if (!response.ok) { 26 | throw new Error(`GET ${endpoint} failed: ${response.status} ${response.statusText}`); 27 | } 28 | 29 | return await response.json(); 30 | } 31 | 32 | /** 33 | * Make a POST request 34 | * @param {string} endpoint - API endpoint 35 | * @param {Object} data - Request body 36 | * @param {Object} headers - Additional headers 37 | * @returns {Promise} Response data 38 | */ 39 | async post(endpoint, data = {}, headers = {}) { 40 | const response = await fetch(`${this.baseURL}${endpoint}`, { 41 | method: 'POST', 42 | headers: { ...this.defaultHeaders, ...headers }, 43 | body: JSON.stringify(data) 44 | }); 45 | 46 | if (!response.ok) { 47 | throw new Error(`POST ${endpoint} failed: ${response.status} ${response.statusText}`); 48 | } 49 | 50 | return await response.json(); 51 | } 52 | 53 | /** 54 | * Make a PUT request 55 | * @param {string} endpoint - API endpoint 56 | * @param {Object} data - Request body 57 | * @param {Object} headers - Additional headers 58 | * @returns {Promise} Response data 59 | */ 60 | async put(endpoint, data = {}, headers = {}) { 61 | const response = await fetch(`${this.baseURL}${endpoint}`, { 62 | method: 'PUT', 63 | headers: { ...this.defaultHeaders, ...headers }, 64 | body: JSON.stringify(data) 65 | }); 66 | 67 | if (!response.ok) { 68 | throw new Error(`PUT ${endpoint} failed: ${response.status} ${response.statusText}`); 69 | } 70 | 71 | return await response.json(); 72 | } 73 | 74 | /** 75 | * Make a DELETE request 76 | * @param {string} endpoint - API endpoint 77 | * @param {Object} headers - Additional headers 78 | * @returns {Promise} Response data 79 | */ 80 | async delete(endpoint, headers = {}) { 81 | const response = await fetch(`${this.baseURL}${endpoint}`, { 82 | method: 'DELETE', 83 | headers: { ...this.defaultHeaders, ...headers } 84 | }); 85 | 86 | if (!response.ok) { 87 | throw new Error(`DELETE ${endpoint} failed: ${response.status} ${response.statusText}`); 88 | } 89 | 90 | return response.status === 204 ? null : await response.json(); 91 | } 92 | 93 | /** 94 | * Set authorization header 95 | * @param {string} token - Authorization token 96 | */ 97 | setAuthToken(token) { 98 | this.defaultHeaders['Authorization'] = `Bearer ${token}`; 99 | } 100 | 101 | /** 102 | * Clear authorization header 103 | */ 104 | clearAuthToken() { 105 | delete this.defaultHeaders['Authorization']; 106 | } 107 | } 108 | 109 | module.exports = ApiHelper; 110 | -------------------------------------------------------------------------------- /config/staging.env.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Staging environment configuration 3 | */ 4 | const stagingConfig = { 5 | // Base URLs 6 | baseURL: 'https://staging.saucedemo.com', 7 | apiBaseURL: 'https://staging-api.saucedemo.com', 8 | 9 | // Authentication 10 | credentials: { 11 | standard_user: { 12 | username: 'standard_user', 13 | password: 'secret_sauce' 14 | }, 15 | admin_user: { 16 | username: 'staging_admin', 17 | password: 'staging_admin_pass' 18 | }, 19 | performance_user: { 20 | username: 'performance_glitch_user', 21 | password: 'secret_sauce' 22 | } 23 | }, 24 | 25 | // Database configuration 26 | database: { 27 | host: 'staging-db.example.com', 28 | port: 5432, 29 | name: 'saucedemo_staging', 30 | user: 'staging_user', 31 | password: 'staging_password', 32 | ssl: true, 33 | pool: { 34 | min: 5, 35 | max: 20, 36 | idleTimeoutMillis: 30000, 37 | connectionTimeoutMillis: 5000 38 | } 39 | }, 40 | 41 | // Browser configuration 42 | browser: { 43 | headless: true, 44 | slowMo: 0, 45 | viewport: { 46 | width: 1920, 47 | height: 1080 48 | }, 49 | timeout: 60000, 50 | navigationTimeout: 60000 51 | }, 52 | 53 | // Test configuration 54 | test: { 55 | timeout: 60000, 56 | expect: { 57 | timeout: 15000 58 | }, 59 | retries: 3, 60 | workers: 6, 61 | fullyParallel: true 62 | }, 63 | 64 | // Reporting 65 | reporting: { 66 | htmlReportDir: 'reports/html-report', 67 | allureResultsDir: 'reports/allure-results', 68 | screenshotMode: 'only-on-failure', 69 | videoMode: 'retain-on-failure', 70 | trace: 'retain-on-failure' 71 | }, 72 | 73 | // External services 74 | services: { 75 | sauceLabs: { 76 | enabled: true, 77 | username: process.env.SAUCE_USERNAME || '', 78 | accessKey: process.env.SAUCE_ACCESS_KEY || '', 79 | buildName: `Staging Build ${new Date().toISOString()}` 80 | }, 81 | browserStack: { 82 | enabled: true, 83 | username: process.env.BROWSERSTACK_USERNAME || '', 84 | accessKey: process.env.BROWSERSTACK_ACCESS_KEY || '', 85 | buildName: `Staging Build ${new Date().toISOString()}` 86 | } 87 | }, 88 | 89 | // Logging 90 | logging: { 91 | level: 'warn', 92 | console: true, 93 | file: { 94 | enabled: true, 95 | path: 'logs/staging.log' 96 | } 97 | }, 98 | 99 | // Feature flags 100 | features: { 101 | enableNewCheckoutFlow: true, 102 | enablePerformanceMonitoring: true, 103 | enableAPITesting: true, 104 | enableE2ETesting: true, 105 | enableLoadTesting: true 106 | }, 107 | 108 | // Third-party integrations 109 | integrations: { 110 | slack: { 111 | enabled: true, 112 | webhook: process.env.SLACK_WEBHOOK || '', 113 | channels: { 114 | failures: '#staging-failures', 115 | reports: '#staging-reports', 116 | releases: '#releases' 117 | } 118 | }, 119 | jira: { 120 | enabled: true, 121 | baseUrl: process.env.JIRA_BASE_URL || '', 122 | token: process.env.JIRA_TOKEN || '', 123 | projectKey: 'STAGING' 124 | }, 125 | newRelic: { 126 | enabled: true, 127 | apiKey: process.env.NEW_RELIC_API_KEY || '', 128 | appId: process.env.NEW_RELIC_APP_ID || '' 129 | } 130 | }, 131 | 132 | // Staging specific settings 133 | staging: { 134 | performanceThreshold: { 135 | pageLoadTime: 3000, 136 | apiResponseTime: 1000 137 | }, 138 | monitoringEnabled: true, 139 | loadTestingEnabled: true, 140 | securityTestingEnabled: true, 141 | crossBrowserTesting: { 142 | enabled: true, 143 | browsers: ['chrome', 'firefox', 'safari', 'edge'] 144 | } 145 | } 146 | }; 147 | 148 | module.exports = stagingConfig; 149 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { defineConfig, devices } = require('@playwright/test'); 3 | require('dotenv').config(); 4 | 5 | /** 6 | * Read environment variables from file. 7 | * https://github.com/motdotla/dotenv 8 | */ 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | module.exports = defineConfig({ 14 | testDir: './tests', 15 | 16 | /* Global test timeout */ 17 | timeout: parseInt(process.env.TIMEOUT) || 30000, 18 | 19 | /* Test expect timeout */ 20 | expect: { 21 | timeout: 5000 22 | }, 23 | 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | 27 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 28 | forbidOnly: !!process.env.CI, 29 | 30 | /* Retry on CI only */ 31 | retries: process.env.CI ? 2 : 0, 32 | 33 | /* Opt out of parallel tests on CI. */ 34 | workers: process.env.CI ? 1 : undefined, 35 | 36 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 37 | reporter: [ 38 | ['html', { outputFolder: 'reports/html-report' }], 39 | ['json', { outputFile: 'reports/test-results.json' }], 40 | ['junit', { outputFile: 'reports/junit-results.xml' }], 41 | // Uncomment for Allure reports 42 | // ['allure-playwright', { outputFolder: 'reports/allure-results' }] 43 | ], 44 | 45 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 46 | use: { 47 | /* Base URL to use in actions like `await page.goto('/')`. */ 48 | baseURL: process.env.BASE_URL || 'https://www.saucedemo.com', 49 | 50 | /* Browser context options */ 51 | viewport: { 52 | width: parseInt(process.env.VIEWPORT_WIDTH) || 1280, 53 | height: parseInt(process.env.VIEWPORT_HEIGHT) || 720 54 | }, 55 | 56 | /* Ignore HTTPS errors */ 57 | ignoreHTTPSErrors: true, 58 | 59 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 60 | trace: 'retain-on-failure', 61 | 62 | /* Screenshot settings */ 63 | screenshot: 'only-on-failure', /**process.env.SCREENSHOT_MODE || */ 64 | 65 | /* Video settings */ 66 | video: 'retain-on-failure', /**process.env.VIDEO_MODE || */ 67 | 68 | /* Slow down operations by the specified amount of milliseconds */ 69 | launchOptions: { 70 | slowMo: parseInt(process.env.SLOW_MO) || 0, 71 | } 72 | }, 73 | 74 | /* Configure projects for major browsers */ 75 | projects: [ 76 | { 77 | name: 'chromium', 78 | use: { 79 | ...devices['Desktop Chrome'], 80 | headless: process.env.HEADLESS === 'true', 81 | }, 82 | }, 83 | 84 | { 85 | name: 'firefox', 86 | use: { 87 | ...devices['Desktop Firefox'], 88 | headless: process.env.HEADLESS === 'true', 89 | }, 90 | }, 91 | 92 | { 93 | name: 'webkit', 94 | use: { 95 | ...devices['Desktop Safari'], 96 | headless: process.env.HEADLESS === 'true', 97 | }, 98 | }, 99 | 100 | /* Test against mobile viewports. */ 101 | { 102 | name: 'Mobile Chrome', 103 | use: { ...devices['Pixel 5'] }, 104 | }, 105 | { 106 | name: 'Mobile Safari', 107 | use: { ...devices['iPhone 12'] }, 108 | }, 109 | 110 | /* Test against branded browsers. */ 111 | { 112 | name: 'Microsoft Edge', 113 | use: { 114 | ...devices['Desktop Edge'], 115 | channel: 'msedge', 116 | headless: process.env.HEADLESS === 'true', 117 | }, 118 | }, 119 | { 120 | name: 'Google Chrome', 121 | use: { 122 | ...devices['Desktop Chrome'], 123 | channel: 'chrome', 124 | headless: process.env.HEADLESS === 'true', 125 | }, 126 | }, 127 | ], 128 | 129 | /* Output directories */ 130 | outputDir: 'screenshots/', 131 | 132 | /* Run your local dev server before starting the tests */ 133 | // webServer: { 134 | // command: 'npm run start', 135 | // url: 'http://localhost:3000', 136 | // reuseExistingServer: !process.env.CI, 137 | // }, 138 | }); 139 | -------------------------------------------------------------------------------- /tests/e2e/checkout.spec.js: -------------------------------------------------------------------------------- 1 | const { test, expect } = require('@playwright/test'); 2 | const HomePage = require('../../pages/HomePage'); 3 | const CartPage = require('../../pages/CartPage'); 4 | const CheckoutPage = require('../../pages/CheckoutPage'); 5 | const TestDataGenerator = require('../../src/utils/testDataGenerator'); 6 | 7 | test.describe('Checkout Flow Tests', () => { 8 | let homePage; 9 | let cartPage; 10 | let checkoutPage; 11 | let testDataGenerator; 12 | 13 | test.beforeEach(async ({ page }) => { 14 | homePage = new HomePage(page); 15 | cartPage = new CartPage(page); 16 | checkoutPage = new CheckoutPage(page); 17 | testDataGenerator = new TestDataGenerator(); 18 | 19 | // Login before each test 20 | await page.goto('/'); 21 | await page.fill('[data-test="username"]', 'standard_user'); 22 | await page.fill('[data-test="password"]', 'secret_sauce'); 23 | await page.click('[data-test="login-button"]'); 24 | 25 | // Verify we're on the inventory page 26 | await expect(page.locator('.title')).toContainText('Products'); 27 | }); 28 | 29 | test('should complete full checkout process', async ({ page }) => { 30 | // Add items to cart 31 | await homePage.addProductToCart('sauce-labs-backpack'); 32 | await homePage.addProductToCart('sauce-labs-bike-light'); 33 | 34 | // Go to cart 35 | await homePage.goToCart(); 36 | 37 | // Verify cart items 38 | await expect(cartPage.cartItems).toHaveCount(2); 39 | 40 | // Proceed to checkout 41 | await cartPage.proceedToCheckout(); 42 | 43 | // Fill checkout information 44 | const userData = testDataGenerator.generateUser(); 45 | await checkoutPage.fillCheckoutInformation( 46 | userData.firstName, 47 | userData.lastName, 48 | userData.address.zipCode 49 | ); 50 | 51 | // Continue to overview 52 | await checkoutPage.continueToOverview(); 53 | 54 | // Verify checkout overview 55 | await expect(checkoutPage.checkoutSummary).toBeVisible(); 56 | 57 | // Complete checkout 58 | await checkoutPage.finishCheckout(); 59 | 60 | // Verify success 61 | await expect(checkoutPage.successMessage).toContainText('Thank you for your order!'); 62 | await expect(checkoutPage.successMessage).toBeVisible(); 63 | }); 64 | 65 | test('should show error when required fields are empty', async ({ page }) => { 66 | // Add item to cart and proceed to checkout 67 | await homePage.addProductToCart('sauce-labs-backpack'); 68 | await homePage.goToCart(); 69 | await cartPage.proceedToCheckout(); 70 | 71 | // Try to continue without filling required fields 72 | await checkoutPage.continueToOverview(); 73 | 74 | // Verify error message 75 | await expect(checkoutPage.errorMessage).toBeVisible(); 76 | await expect(checkoutPage.errorMessage).toContainText('First Name is required'); 77 | }); 78 | 79 | test('should calculate total price correctly', async ({ page }) => { 80 | // Add multiple items to cart 81 | await homePage.addProductToCart('sauce-labs-backpack'); 82 | await homePage.addProductToCart('sauce-labs-bike-light'); 83 | await homePage.addProductToCart('sauce-labs-bolt-t-shirt'); 84 | 85 | // Go to cart and checkout 86 | await homePage.goToCart(); 87 | await cartPage.proceedToCheckout(); 88 | 89 | // Fill checkout information 90 | const userData = testDataGenerator.generateUser(); 91 | await checkoutPage.fillCheckoutInformation( 92 | userData.firstName, 93 | userData.lastName, 94 | userData.address.zipCode 95 | ); 96 | 97 | // Continue to overview 98 | await checkoutPage.continueToOverview(); 99 | 100 | // Verify price calculations 101 | const subtotal = await checkoutPage.getSubtotal(); 102 | const tax = await checkoutPage.getTax(); 103 | const total = await checkoutPage.getTotal(); 104 | 105 | expect(total).toBeGreaterThan(subtotal); 106 | expect(tax).toBeGreaterThan(0); 107 | }); 108 | 109 | test('should allow user to cancel checkout', async ({ page }) => { 110 | // Add item to cart and proceed to checkout 111 | await homePage.addProductToCart('sauce-labs-backpack'); 112 | await homePage.goToCart(); 113 | await cartPage.proceedToCheckout(); 114 | 115 | // Cancel checkout 116 | await checkoutPage.cancelCheckout(); 117 | 118 | // Verify we're back to cart page 119 | await expect(page.url()).toContain('/cart.html'); 120 | await expect(cartPage.cartItems).toHaveCount(1); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /data/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "username": "standard_user", 5 | "password": "secret_sauce", 6 | "firstName": "John", 7 | "lastName": "Doe", 8 | "email": "john.doe@example.com", 9 | "role": "customer", 10 | "status": "active", 11 | "address": { 12 | "street": "123 Main St", 13 | "city": "Anytown", 14 | "state": "CA", 15 | "zipCode": "12345", 16 | "country": "USA" 17 | }, 18 | "preferences": { 19 | "newsletter": true, 20 | "notifications": true 21 | }, 22 | "createdAt": "2024-01-15T10:30:00.000Z", 23 | "lastLogin": "2024-09-10T14:22:00.000Z" 24 | }, 25 | { 26 | "id": 2, 27 | "username": "locked_out_user", 28 | "password": "secret_sauce", 29 | "firstName": "Jane", 30 | "lastName": "Smith", 31 | "email": "jane.smith@example.com", 32 | "role": "customer", 33 | "status": "locked", 34 | "address": { 35 | "street": "456 Oak Ave", 36 | "city": "Springfield", 37 | "state": "IL", 38 | "zipCode": "62701", 39 | "country": "USA" 40 | }, 41 | "preferences": { 42 | "newsletter": false, 43 | "notifications": true 44 | }, 45 | "createdAt": "2024-02-20T09:15:00.000Z", 46 | "lastLogin": "2024-08-25T11:45:00.000Z" 47 | }, 48 | { 49 | "id": 3, 50 | "username": "problem_user", 51 | "password": "secret_sauce", 52 | "firstName": "Bob", 53 | "lastName": "Johnson", 54 | "email": "bob.johnson@example.com", 55 | "role": "customer", 56 | "status": "active", 57 | "address": { 58 | "street": "789 Pine Rd", 59 | "city": "Portland", 60 | "state": "OR", 61 | "zipCode": "97201", 62 | "country": "USA" 63 | }, 64 | "preferences": { 65 | "newsletter": true, 66 | "notifications": false 67 | }, 68 | "createdAt": "2024-03-10T16:45:00.000Z", 69 | "lastLogin": "2024-09-08T13:20:00.000Z" 70 | }, 71 | { 72 | "id": 4, 73 | "username": "performance_glitch_user", 74 | "password": "secret_sauce", 75 | "firstName": "Alice", 76 | "lastName": "Williams", 77 | "email": "alice.williams@example.com", 78 | "role": "customer", 79 | "status": "active", 80 | "address": { 81 | "street": "321 Elm St", 82 | "city": "Denver", 83 | "state": "CO", 84 | "zipCode": "80201", 85 | "country": "USA" 86 | }, 87 | "preferences": { 88 | "newsletter": true, 89 | "notifications": true 90 | }, 91 | "createdAt": "2024-04-05T12:00:00.000Z", 92 | "lastLogin": "2024-09-09T10:30:00.000Z" 93 | }, 94 | { 95 | "id": 5, 96 | "username": "error_user", 97 | "password": "secret_sauce", 98 | "firstName": "Charlie", 99 | "lastName": "Brown", 100 | "email": "charlie.brown@example.com", 101 | "role": "customer", 102 | "status": "active", 103 | "address": { 104 | "street": "654 Maple Dr", 105 | "city": "Seattle", 106 | "state": "WA", 107 | "zipCode": "98101", 108 | "country": "USA" 109 | }, 110 | "preferences": { 111 | "newsletter": false, 112 | "notifications": false 113 | }, 114 | "createdAt": "2024-05-12T08:30:00.000Z", 115 | "lastLogin": "2024-09-07T15:45:00.000Z" 116 | }, 117 | { 118 | "id": 6, 119 | "username": "visual_user", 120 | "password": "secret_sauce", 121 | "firstName": "Diana", 122 | "lastName": "Davis", 123 | "email": "diana.davis@example.com", 124 | "role": "customer", 125 | "status": "active", 126 | "address": { 127 | "street": "987 Cedar Ln", 128 | "city": "Austin", 129 | "state": "TX", 130 | "zipCode": "73301", 131 | "country": "USA" 132 | }, 133 | "preferences": { 134 | "newsletter": true, 135 | "notifications": true 136 | }, 137 | "createdAt": "2024-06-18T14:15:00.000Z", 138 | "lastLogin": "2024-09-11T09:10:00.000Z" 139 | }, 140 | { 141 | "id": 7, 142 | "username": "admin_user", 143 | "password": "admin_secret", 144 | "firstName": "Admin", 145 | "lastName": "User", 146 | "email": "admin@saucedemo.com", 147 | "role": "admin", 148 | "status": "active", 149 | "address": { 150 | "street": "100 Admin Blvd", 151 | "city": "San Francisco", 152 | "state": "CA", 153 | "zipCode": "94102", 154 | "country": "USA" 155 | }, 156 | "preferences": { 157 | "newsletter": false, 158 | "notifications": true 159 | }, 160 | "createdAt": "2024-01-01T00:00:00.000Z", 161 | "lastLogin": "2024-09-11T08:00:00.000Z" 162 | } 163 | ] 164 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Production environment configuration 3 | */ 4 | const prodConfig = { 5 | // Base URLs 6 | baseURL: 'https://www.saucedemo.com', 7 | apiBaseURL: 'https://api.saucedemo.com', 8 | 9 | // Authentication 10 | credentials: { 11 | standard_user: { 12 | username: 'standard_user', 13 | password: 'secret_sauce' 14 | }, 15 | // NOTE: In production, credentials should be minimal and secure 16 | monitoring_user: { 17 | username: process.env.PROD_MONITOR_USER || '', 18 | password: process.env.PROD_MONITOR_PASS || '' 19 | } 20 | }, 21 | 22 | // Database configuration 23 | database: { 24 | host: process.env.PROD_DB_HOST || '', 25 | port: parseInt(process.env.PROD_DB_PORT) || 5432, 26 | name: process.env.PROD_DB_NAME || '', 27 | user: process.env.PROD_DB_USER || '', 28 | password: process.env.PROD_DB_PASS || '', 29 | ssl: true, 30 | pool: { 31 | min: 10, 32 | max: 50, 33 | idleTimeoutMillis: 30000, 34 | connectionTimeoutMillis: 10000 35 | } 36 | }, 37 | 38 | // Browser configuration 39 | browser: { 40 | headless: true, 41 | slowMo: 0, 42 | viewport: { 43 | width: 1920, 44 | height: 1080 45 | }, 46 | timeout: 90000, 47 | navigationTimeout: 90000 48 | }, 49 | 50 | // Test configuration 51 | test: { 52 | timeout: 90000, 53 | expect: { 54 | timeout: 20000 55 | }, 56 | retries: 5, 57 | workers: 8, 58 | fullyParallel: true 59 | }, 60 | 61 | // Reporting 62 | reporting: { 63 | htmlReportDir: 'reports/html-report', 64 | allureResultsDir: 'reports/allure-results', 65 | screenshotMode: 'only-on-failure', 66 | videoMode: 'retain-on-failure', 67 | trace: 'retain-on-failure' 68 | }, 69 | 70 | // External services 71 | services: { 72 | sauceLabs: { 73 | enabled: false, // Typically disabled in prod monitoring 74 | username: process.env.SAUCE_USERNAME || '', 75 | accessKey: process.env.SAUCE_ACCESS_KEY || '' 76 | }, 77 | browserStack: { 78 | enabled: false, // Typically disabled in prod monitoring 79 | username: process.env.BROWSERSTACK_USERNAME || '', 80 | accessKey: process.env.BROWSERSTACK_ACCESS_KEY || '' 81 | } 82 | }, 83 | 84 | // Logging 85 | logging: { 86 | level: 'error', 87 | console: false, 88 | file: { 89 | enabled: true, 90 | path: 'logs/prod.log', 91 | maxSize: '100MB', 92 | maxFiles: 10 93 | } 94 | }, 95 | 96 | // Feature flags 97 | features: { 98 | enableNewCheckoutFlow: true, 99 | enablePerformanceMonitoring: true, 100 | enableAPITesting: false, // Limited API testing in prod 101 | enableE2ETesting: false, // Limited E2E testing in prod 102 | enableLoadTesting: false, 103 | enableSmokeTests: true 104 | }, 105 | 106 | // Third-party integrations 107 | integrations: { 108 | slack: { 109 | enabled: true, 110 | webhook: process.env.SLACK_WEBHOOK || '', 111 | channels: { 112 | critical: '#prod-critical', 113 | alerts: '#prod-alerts', 114 | reports: '#prod-reports' 115 | } 116 | }, 117 | jira: { 118 | enabled: true, 119 | baseUrl: process.env.JIRA_BASE_URL || '', 120 | token: process.env.JIRA_TOKEN || '', 121 | projectKey: 'PROD' 122 | }, 123 | newRelic: { 124 | enabled: true, 125 | apiKey: process.env.NEW_RELIC_API_KEY || '', 126 | appId: process.env.NEW_RELIC_APP_ID || '' 127 | }, 128 | datadog: { 129 | enabled: true, 130 | apiKey: process.env.DATADOG_API_KEY || '', 131 | appKey: process.env.DATADOG_APP_KEY || '' 132 | }, 133 | pagerDuty: { 134 | enabled: true, 135 | apiKey: process.env.PAGERDUTY_API_KEY || '', 136 | serviceKey: process.env.PAGERDUTY_SERVICE_KEY || '' 137 | } 138 | }, 139 | 140 | // Production specific settings 141 | production: { 142 | readOnlyMode: true, // Prevent data modifications 143 | smokeTestsOnly: true, 144 | maxTestDuration: 600000, // 10 minutes 145 | monitoringInterval: 300000, // 5 minutes 146 | healthChecks: { 147 | enabled: true, 148 | endpoints: [ 149 | '/health', 150 | '/api/health', 151 | '/status' 152 | ] 153 | }, 154 | performanceThreshold: { 155 | pageLoadTime: 2000, 156 | apiResponseTime: 500, 157 | errorRate: 0.01 // 1% 158 | }, 159 | alerting: { 160 | enabled: true, 161 | criticalFailures: true, 162 | performanceDegradation: true, 163 | uptime: true 164 | }, 165 | backup: { 166 | enabled: true, 167 | schedule: '0 2 * * *', // Daily at 2 AM 168 | retention: 30 // days 169 | } 170 | } 171 | }; 172 | 173 | module.exports = prodConfig; 174 | -------------------------------------------------------------------------------- /pages/LoginPage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Login Page Object Model 3 | */ 4 | class LoginPage { 5 | constructor(page) { 6 | this.page = page; 7 | 8 | // Selectors 9 | this.usernameInput = page.locator('[data-test="username"]'); 10 | this.passwordInput = page.locator('[data-test="password"]'); 11 | this.loginButton = page.locator('[data-test="login-button"]'); 12 | this.errorMessage = page.locator('[data-test="error"]'); 13 | this.errorCloseButton = page.locator('.error-button'); 14 | this.logoImage = page.locator('.login_logo'); 15 | this.credentialsText = page.locator('#login_credentials'); 16 | this.passwordText = page.locator('.login_password'); 17 | } 18 | 19 | /** 20 | * Navigate to login page 21 | */ 22 | async goto() { 23 | await this.page.goto('/'); 24 | } 25 | 26 | /** 27 | * Perform login with given credentials 28 | * @param {string} username - Username 29 | * @param {string} password - Password 30 | */ 31 | async login(username, password) { 32 | await this.usernameInput.fill(username); 33 | await this.passwordInput.fill(password); 34 | await this.loginButton.click(); 35 | } 36 | 37 | /** 38 | * Clear username field 39 | */ 40 | async clearUsername() { 41 | await this.usernameInput.clear(); 42 | } 43 | 44 | /** 45 | * Clear password field 46 | */ 47 | async clearPassword() { 48 | await this.passwordInput.clear(); 49 | } 50 | 51 | /** 52 | * Get username field value 53 | * @returns {Promise} Username value 54 | */ 55 | async getUsernameValue() { 56 | return await this.usernameInput.inputValue(); 57 | } 58 | 59 | /** 60 | * Get password field value 61 | * @returns {Promise} Password value 62 | */ 63 | async getPasswordValue() { 64 | return await this.passwordInput.inputValue(); 65 | } 66 | 67 | /** 68 | * Get error message text 69 | * @returns {Promise} Error message 70 | */ 71 | async getErrorMessage() { 72 | return await this.errorMessage.textContent(); 73 | } 74 | 75 | /** 76 | * Check if error message is visible 77 | * @returns {Promise} True if error is visible 78 | */ 79 | async isErrorVisible() { 80 | return await this.errorMessage.isVisible(); 81 | } 82 | 83 | /** 84 | * Close error message by clicking X button 85 | */ 86 | async closeErrorMessage() { 87 | await this.errorCloseButton.click(); 88 | } 89 | 90 | /** 91 | * Check if login button is enabled 92 | * @returns {Promise} True if button is enabled 93 | */ 94 | async isLoginButtonEnabled() { 95 | return await this.loginButton.isEnabled(); 96 | } 97 | 98 | /** 99 | * Get login button text 100 | * @returns {Promise} Button text 101 | */ 102 | async getLoginButtonText() { 103 | return await this.loginButton.textContent(); 104 | } 105 | 106 | /** 107 | * Check if username field is focused 108 | * @returns {Promise} True if focused 109 | */ 110 | async isUsernameFieldFocused() { 111 | return await this.usernameInput.isFocused(); 112 | } 113 | 114 | /** 115 | * Check if password field is focused 116 | * @returns {Promise} True if focused 117 | */ 118 | async isPasswordFieldFocused() { 119 | return await this.passwordInput.isFocused(); 120 | } 121 | 122 | /** 123 | * Get accepted usernames from the page 124 | * @returns {Promise} Array of usernames 125 | */ 126 | async getAcceptedUsernames() { 127 | const credentialsText = await this.credentialsText.textContent(); 128 | const usernames = credentialsText.match(/\b\w+_user\b/g) || []; 129 | return usernames; 130 | } 131 | 132 | /** 133 | * Get password for all users 134 | * @returns {Promise} Password text 135 | */ 136 | async getPasswordForAllUsers() { 137 | const passwordText = await this.passwordText.textContent(); 138 | const password = passwordText.match(/Password for all users:\s*(\w+)/); 139 | return password ? password[1] : ''; 140 | } 141 | 142 | /** 143 | * Login with standard user 144 | */ 145 | async loginWithStandardUser() { 146 | await this.login('standard_user', 'secret_sauce'); 147 | } 148 | 149 | /** 150 | * Login with locked out user 151 | */ 152 | async loginWithLockedOutUser() { 153 | await this.login('locked_out_user', 'secret_sauce'); 154 | } 155 | 156 | /** 157 | * Login with problem user 158 | */ 159 | async loginWithProblemUser() { 160 | await this.login('problem_user', 'secret_sauce'); 161 | } 162 | 163 | /** 164 | * Login with performance glitch user 165 | */ 166 | async loginWithPerformanceGlitchUser() { 167 | await this.login('performance_glitch_user', 'secret_sauce'); 168 | } 169 | 170 | /** 171 | * Wait for page to load 172 | */ 173 | async waitForLoad() { 174 | await this.logoImage.waitFor({ state: 'visible' }); 175 | await this.usernameInput.waitFor({ state: 'visible' }); 176 | await this.passwordInput.waitFor({ state: 'visible' }); 177 | await this.loginButton.waitFor({ state: 'visible' }); 178 | } 179 | } 180 | 181 | module.exports = LoginPage; 182 | -------------------------------------------------------------------------------- /data/credentials.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test credentials for different environments and user types 3 | */ 4 | const credentials = { 5 | // Standard valid users 6 | valid: { 7 | standard_user: { 8 | username: 'standard_user', 9 | password: 'secret_sauce', 10 | description: 'Standard user with full access' 11 | }, 12 | performance_glitch_user: { 13 | username: 'performance_glitch_user', 14 | password: 'secret_sauce', 15 | description: 'User that experiences performance issues' 16 | }, 17 | error_user: { 18 | username: 'error_user', 19 | password: 'secret_sauce', 20 | description: 'User that encounters various errors' 21 | }, 22 | visual_user: { 23 | username: 'visual_user', 24 | password: 'secret_sauce', 25 | description: 'User for visual testing' 26 | } 27 | }, 28 | 29 | // Invalid/Problem users 30 | invalid: { 31 | locked_out_user: { 32 | username: 'locked_out_user', 33 | password: 'secret_sauce', 34 | description: 'User that is locked out', 35 | expectedError: 'Sorry, this user has been locked out.' 36 | }, 37 | problem_user: { 38 | username: 'problem_user', 39 | password: 'secret_sauce', 40 | description: 'User with various UI problems' 41 | }, 42 | invalid_user: { 43 | username: 'invalid_user', 44 | password: 'wrong_password', 45 | description: 'Non-existent user credentials', 46 | expectedError: 'Username and password do not match any user in this service' 47 | } 48 | }, 49 | 50 | // Admin users (for different environments) 51 | admin: { 52 | dev_admin: { 53 | username: process.env.DEV_ADMIN_USER || 'dev_admin', 54 | password: process.env.DEV_ADMIN_PASS || 'dev_admin_pass', 55 | environment: 'development' 56 | }, 57 | qa_admin: { 58 | username: process.env.QA_ADMIN_USER || 'qa_admin', 59 | password: process.env.QA_ADMIN_PASS || 'qa_admin_pass', 60 | environment: 'qa' 61 | }, 62 | staging_admin: { 63 | username: process.env.STAGING_ADMIN_USER || 'staging_admin', 64 | password: process.env.STAGING_ADMIN_PASS || 'staging_admin_pass', 65 | environment: 'staging' 66 | } 67 | }, 68 | 69 | // API credentials 70 | api: { 71 | service_account: { 72 | clientId: process.env.API_CLIENT_ID || 'test_client_id', 73 | clientSecret: process.env.API_CLIENT_SECRET || 'test_client_secret', 74 | scope: 'read write' 75 | }, 76 | readonly_account: { 77 | clientId: process.env.READONLY_CLIENT_ID || 'readonly_client', 78 | clientSecret: process.env.READONLY_CLIENT_SECRET || 'readonly_secret', 79 | scope: 'read' 80 | } 81 | }, 82 | 83 | // Database credentials 84 | database: { 85 | test_db: { 86 | host: process.env.TEST_DB_HOST || 'localhost', 87 | port: process.env.TEST_DB_PORT || 5432, 88 | username: process.env.TEST_DB_USER || 'test_user', 89 | password: process.env.TEST_DB_PASS || 'test_pass', 90 | database: process.env.TEST_DB_NAME || 'test_db' 91 | } 92 | }, 93 | 94 | // Third-party service credentials 95 | external: { 96 | sauce_labs: { 97 | username: process.env.SAUCE_USERNAME || '', 98 | accessKey: process.env.SAUCE_ACCESS_KEY || '' 99 | }, 100 | browserstack: { 101 | username: process.env.BROWSERSTACK_USERNAME || '', 102 | accessKey: process.env.BROWSERSTACK_ACCESS_KEY || '' 103 | } 104 | } 105 | }; 106 | 107 | /** 108 | * Get credentials for specific user type and environment 109 | * @param {string} userType - Type of user (valid, invalid, admin, api, etc.) 110 | * @param {string} userKey - Specific user key 111 | * @returns {Object} User credentials 112 | */ 113 | function getCredentials(userType, userKey) { 114 | if (credentials[userType] && credentials[userType][userKey]) { 115 | return credentials[userType][userKey]; 116 | } 117 | throw new Error(`Credentials not found for ${userType}.${userKey}`); 118 | } 119 | 120 | /** 121 | * Get all valid users 122 | * @returns {Object} All valid user credentials 123 | */ 124 | function getValidUsers() { 125 | return credentials.valid; 126 | } 127 | 128 | /** 129 | * Get all invalid users 130 | * @returns {Object} All invalid user credentials 131 | */ 132 | function getInvalidUsers() { 133 | return credentials.invalid; 134 | } 135 | 136 | /** 137 | * Get random valid user 138 | * @returns {Object} Random valid user credentials 139 | */ 140 | function getRandomValidUser() { 141 | const validUsers = Object.keys(credentials.valid); 142 | const randomUser = validUsers[Math.floor(Math.random() * validUsers.length)]; 143 | return credentials.valid[randomUser]; 144 | } 145 | 146 | /** 147 | * Get admin credentials for environment 148 | * @param {string} environment - Environment name (dev, qa, staging) 149 | * @returns {Object} Admin credentials 150 | */ 151 | function getAdminCredentials(environment) { 152 | const adminKey = `${environment}_admin`; 153 | return credentials.admin[adminKey] || credentials.admin.dev_admin; 154 | } 155 | 156 | module.exports = { 157 | credentials, 158 | getCredentials, 159 | getValidUsers, 160 | getInvalidUsers, 161 | getRandomValidUser, 162 | getAdminCredentials 163 | }; 164 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | #schedule: 6 | # Run tests daily at 2 AM UTC 7 | #- cron: '0 2 * * *' 8 | 9 | jobs: 10 | test: 11 | timeout-minutes: 60 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | project: [chromium, firefox, webkit] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: '18' 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Install Playwright Browsers 31 | run: npx playwright install --with-deps 32 | 33 | - name: Create environment file 34 | run: | 35 | echo "BASE_URL=https://www.saucedemo.com" >> .env 36 | echo "API_BASE_URL=https://api.saucedemo.com" >> .env 37 | echo "TIMEOUT=30000" >> .env 38 | echo "HEADLESS=true" >> .env 39 | echo "BROWSER=chromium" >> .env 40 | 41 | - name: Run Playwright tests 42 | run: npx playwright test --project=${{ matrix.project }} 43 | env: 44 | CI: true 45 | 46 | - name: Upload Playwright Report 47 | uses: actions/upload-artifact@v4 48 | if: always() 49 | with: 50 | name: playwright-report-${{ matrix.project }} 51 | path: reports/html-report/ 52 | retention-days: 30 53 | 54 | - name: Upload Screenshots 55 | uses: actions/upload-artifact@v4 56 | if: failure() 57 | with: 58 | name: playwright-screenshots-${{ matrix.project }} 59 | path: screenshots/ 60 | retention-days: 7 61 | 62 | - name: Upload Videos 63 | uses: actions/upload-artifact@v4 64 | if: failure() 65 | with: 66 | name: playwright-videos-${{ matrix.project }} 67 | path: screenshots/videos/ 68 | retention-days: 7 69 | 70 | # API testing 71 | api-tests: 72 | timeout-minutes: 30 73 | runs-on: ubuntu-latest 74 | 75 | steps: 76 | - uses: actions/checkout@v4 77 | 78 | - uses: actions/setup-node@v4 79 | with: 80 | node-version: '18' 81 | cache: 'npm' 82 | 83 | - name: Install dependencies 84 | run: npm ci 85 | 86 | - name: Run API tests 87 | run: npx playwright test tests/api/ --project=chromium 88 | env: 89 | CI: true 90 | API_BASE_URL: https://api.saucedemo.com 91 | 92 | - name: Upload API Test Results 93 | uses: actions/upload-artifact@v4 94 | if: always() 95 | with: 96 | name: api-test-results 97 | path: reports/ 98 | retention-days: 30 99 | 100 | # Notification job 101 | notify: 102 | runs-on: ubuntu-latest 103 | needs: [test, api-tests] 104 | if: always() 105 | 106 | steps: 107 | - name: Notify on failure 108 | if: failure() 109 | run: | 110 | if [ -n "${{ secrets.SLACK_WEBHOOK }}" ]; then 111 | curl -X POST -H 'Content-type: application/json' \ 112 | --data '{ 113 | "channel": "#test-failures", 114 | "text": "❌ Playwright tests failed in ${{ github.repository }}", 115 | "attachments": [ 116 | { 117 | "color": "danger", 118 | "fields": [ 119 | { 120 | "title": "Branch", 121 | "value": "${{ github.ref_name }}", 122 | "short": true 123 | }, 124 | { 125 | "title": "Commit", 126 | "value": "${{ github.sha }}", 127 | "short": true 128 | }, 129 | { 130 | "title": "Build URL", 131 | "value": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", 132 | "short": false 133 | } 134 | ] 135 | } 136 | ] 137 | }' \ 138 | "${{ secrets.SLACK_WEBHOOK }}" 139 | else 140 | echo "SLACK_WEBHOOK secret not configured, skipping notification" 141 | fi 142 | 143 | - name: Notify on success 144 | if: success() 145 | run: | 146 | if [ -n "${{ secrets.SLACK_WEBHOOK }}" ]; then 147 | curl -X POST -H 'Content-type: application/json' \ 148 | --data '{ 149 | "channel": "#test-results", 150 | "text": "✅ Playwright tests passed in ${{ github.repository }}", 151 | "attachments": [ 152 | { 153 | "color": "good", 154 | "fields": [ 155 | { 156 | "title": "Branch", 157 | "value": "${{ github.ref_name }}", 158 | "short": true 159 | }, 160 | { 161 | "title": "Commit", 162 | "value": "${{ github.sha }}", 163 | "short": true 164 | } 165 | ] 166 | } 167 | ] 168 | }' \ 169 | "${{ secrets.SLACK_WEBHOOK }}" 170 | else 171 | echo "SLACK_WEBHOOK secret not configured, skipping notification" 172 | fi 173 | -------------------------------------------------------------------------------- /tests/e2e/payment.spec.js: -------------------------------------------------------------------------------- 1 | const { test, expect } = require('@playwright/test'); 2 | const HomePage = require('../../pages/HomePage'); 3 | const CartPage = require('../../pages/CartPage'); 4 | 5 | test.describe('Payment Tests', () => { 6 | let homePage; 7 | let cartPage; 8 | 9 | test.beforeEach(async ({ page }) => { 10 | homePage = new HomePage(page); 11 | cartPage = new CartPage(page); 12 | 13 | // Login before each test 14 | await page.goto('/'); 15 | await page.fill('[data-test="username"]', 'standard_user'); 16 | await page.fill('[data-test="password"]', 'secret_sauce'); 17 | await page.click('[data-test="login-button"]'); 18 | }); 19 | 20 | test('should display correct item prices', async ({ page }) => { 21 | // Verify product prices are displayed 22 | const products = await homePage.getAllProducts(); 23 | 24 | for (const product of products) { 25 | const priceText = await product.locator('.inventory_item_price').textContent(); 26 | expect(priceText).toMatch(/\$\d+\.\d{2}/); // Price format $XX.XX 27 | } 28 | }); 29 | 30 | test('should calculate cart total correctly', async ({ page }) => { 31 | // Add multiple items with known prices 32 | await homePage.addProductToCart('sauce-labs-backpack'); 33 | await homePage.addProductToCart('sauce-labs-bike-light'); 34 | 35 | // Go to cart 36 | await homePage.goToCart(); 37 | 38 | // Get individual item prices 39 | const itemPrices = await cartPage.getItemPrices(); 40 | const expectedSubtotal = itemPrices.reduce((sum, price) => sum + price, 0); 41 | 42 | // Proceed to checkout to see totals 43 | await cartPage.proceedToCheckout(); 44 | await page.fill('[data-test="firstName"]', 'John'); 45 | await page.fill('[data-test="lastName"]', 'Doe'); 46 | await page.fill('[data-test="postalCode"]', '12345'); 47 | await page.click('[data-test="continue"]'); 48 | 49 | // Verify subtotal matches 50 | const displayedSubtotal = await page.locator('.summary_subtotal_label').textContent(); 51 | const subtotalValue = parseFloat(displayedSubtotal.replace('Item total: $', '')); 52 | 53 | expect(Math.abs(subtotalValue - expectedSubtotal)).toBeLessThan(0.01); 54 | }); 55 | 56 | test('should apply tax correctly', async ({ page }) => { 57 | // Add item to cart 58 | await homePage.addProductToCart('sauce-labs-backpack'); 59 | await homePage.goToCart(); 60 | await cartPage.proceedToCheckout(); 61 | 62 | // Fill checkout info 63 | await page.fill('[data-test="firstName"]', 'John'); 64 | await page.fill('[data-test="lastName"]', 'Doe'); 65 | await page.fill('[data-test="postalCode"]', '12345'); 66 | await page.click('[data-test="continue"]'); 67 | 68 | // Get tax information 69 | const taxLabel = await page.locator('.summary_tax_label').textContent(); 70 | const taxAmount = parseFloat(taxLabel.replace('Tax: $', '')); 71 | 72 | // Tax should be a positive number 73 | expect(taxAmount).toBeGreaterThan(0); 74 | 75 | // Tax should be reasonable (between 1% and 15% of subtotal) 76 | const subtotalLabel = await page.locator('.summary_subtotal_label').textContent(); 77 | const subtotalAmount = parseFloat(subtotalLabel.replace('Item total: $', '')); 78 | const taxRate = taxAmount / subtotalAmount; 79 | 80 | expect(taxRate).toBeGreaterThan(0.01); 81 | expect(taxRate).toBeLessThan(0.15); 82 | }); 83 | 84 | test('should show final total correctly', async ({ page }) => { 85 | // Add items to cart 86 | await homePage.addProductToCart('sauce-labs-backpack'); 87 | await homePage.addProductToCart('sauce-labs-bike-light'); 88 | 89 | // Complete checkout process 90 | await homePage.goToCart(); 91 | await cartPage.proceedToCheckout(); 92 | 93 | await page.fill('[data-test="firstName"]', 'John'); 94 | await page.fill('[data-test="lastName"]', 'Doe'); 95 | await page.fill('[data-test="postalCode"]', '12345'); 96 | await page.click('[data-test="continue"]'); 97 | 98 | // Get all price components 99 | const subtotalLabel = await page.locator('.summary_subtotal_label').textContent(); 100 | const taxLabel = await page.locator('.summary_tax_label').textContent(); 101 | const totalLabel = await page.locator('.summary_total_label').textContent(); 102 | 103 | const subtotal = parseFloat(subtotalLabel.replace('Item total: $', '')); 104 | const tax = parseFloat(taxLabel.replace('Tax: $', '')); 105 | const total = parseFloat(totalLabel.replace('Total: $', '')); 106 | 107 | // Verify total = subtotal + tax 108 | expect(Math.abs(total - (subtotal + tax))).toBeLessThan(0.01); 109 | }); 110 | 111 | test('should handle price display for different products', async ({ page }) => { 112 | // Test that all products have valid price formats 113 | const productPrices = await page.locator('.inventory_item_price').all(); 114 | 115 | for (const priceElement of productPrices) { 116 | const priceText = await priceElement.textContent(); 117 | 118 | // Verify price format 119 | expect(priceText).toMatch(/^\$\d+\.\d{2}$/); 120 | 121 | // Verify price is a reasonable amount 122 | const priceValue = parseFloat(priceText.replace('$', '')); 123 | expect(priceValue).toBeGreaterThan(0); 124 | expect(priceValue).toBeLessThan(1000); // Reasonable upper limit 125 | } 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/utils/dbHelper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Database Helper utilities for database operations 3 | */ 4 | const { Pool } = require('pg'); // PostgreSQL client 5 | 6 | class DbHelper { 7 | constructor(config = {}) { 8 | this.config = { 9 | host: config.host || process.env.DB_HOST || 'localhost', 10 | port: config.port || process.env.DB_PORT || 5432, 11 | database: config.database || process.env.DB_NAME, 12 | user: config.user || process.env.DB_USER, 13 | password: config.password || process.env.DB_PASSWORD, 14 | max: 10, // max number of clients in pool 15 | idleTimeoutMillis: 30000, // how long a client is allowed to remain idle 16 | connectionTimeoutMillis: 2000, // how long to wait for connection 17 | }; 18 | 19 | this.pool = null; 20 | } 21 | 22 | /** 23 | * Initialize database connection pool 24 | */ 25 | async connect() { 26 | try { 27 | this.pool = new Pool(this.config); 28 | 29 | // Test connection 30 | const client = await this.pool.connect(); 31 | console.log('Database connected successfully'); 32 | client.release(); 33 | 34 | return this.pool; 35 | } catch (error) { 36 | console.error('Database connection error:', error.message); 37 | throw error; 38 | } 39 | } 40 | 41 | /** 42 | * Execute a query 43 | * @param {string} query - SQL query 44 | * @param {Array} params - Query parameters 45 | * @returns {Promise} Query result 46 | */ 47 | async query(query, params = []) { 48 | if (!this.pool) { 49 | await this.connect(); 50 | } 51 | 52 | try { 53 | const result = await this.pool.query(query, params); 54 | return result; 55 | } catch (error) { 56 | console.error('Database query error:', error.message); 57 | throw error; 58 | } 59 | } 60 | 61 | /** 62 | * Get a single record 63 | * @param {string} query - SQL query 64 | * @param {Array} params - Query parameters 65 | * @returns {Promise} Single record or null 66 | */ 67 | async getOne(query, params = []) { 68 | const result = await this.query(query, params); 69 | return result.rows.length > 0 ? result.rows[0] : null; 70 | } 71 | 72 | /** 73 | * Get multiple records 74 | * @param {string} query - SQL query 75 | * @param {Array} params - Query parameters 76 | * @returns {Promise} Array of records 77 | */ 78 | async getMany(query, params = []) { 79 | const result = await this.query(query, params); 80 | return result.rows; 81 | } 82 | 83 | /** 84 | * Insert a record 85 | * @param {string} table - Table name 86 | * @param {Object} data - Data to insert 87 | * @returns {Promise} Inserted record 88 | */ 89 | async insert(table, data) { 90 | const keys = Object.keys(data); 91 | const values = Object.values(data); 92 | const placeholders = keys.map((_, index) => `$${index + 1}`).join(', '); 93 | 94 | const query = ` 95 | INSERT INTO ${table} (${keys.join(', ')}) 96 | VALUES (${placeholders}) 97 | RETURNING * 98 | `; 99 | 100 | const result = await this.query(query, values); 101 | return result.rows[0]; 102 | } 103 | 104 | /** 105 | * Update a record 106 | * @param {string} table - Table name 107 | * @param {Object} data - Data to update 108 | * @param {string} whereClause - WHERE condition 109 | * @param {Array} whereParams - WHERE parameters 110 | * @returns {Promise} Updated record 111 | */ 112 | async update(table, data, whereClause, whereParams = []) { 113 | const keys = Object.keys(data); 114 | const values = Object.values(data); 115 | 116 | const setClause = keys.map((key, index) => `${key} = $${index + 1}`).join(', '); 117 | const whereParamOffset = values.length; 118 | 119 | const query = ` 120 | UPDATE ${table} 121 | SET ${setClause} 122 | WHERE ${whereClause} 123 | RETURNING * 124 | `; 125 | 126 | const allParams = [...values, ...whereParams]; 127 | const result = await this.query(query, allParams); 128 | return result.rows[0]; 129 | } 130 | 131 | /** 132 | * Delete records 133 | * @param {string} table - Table name 134 | * @param {string} whereClause - WHERE condition 135 | * @param {Array} whereParams - WHERE parameters 136 | * @returns {Promise} Number of deleted records 137 | */ 138 | async delete(table, whereClause, whereParams = []) { 139 | const query = `DELETE FROM ${table} WHERE ${whereClause}`; 140 | const result = await this.query(query, whereParams); 141 | return result.rowCount; 142 | } 143 | 144 | /** 145 | * Clean up test data 146 | * @param {Array} tables - Tables to clean 147 | */ 148 | async cleanupTestData(tables = []) { 149 | for (const table of tables) { 150 | await this.query(`DELETE FROM ${table} WHERE created_by = 'test'`); 151 | } 152 | } 153 | 154 | /** 155 | * Begin transaction 156 | */ 157 | async beginTransaction() { 158 | return await this.query('BEGIN'); 159 | } 160 | 161 | /** 162 | * Commit transaction 163 | */ 164 | async commitTransaction() { 165 | return await this.query('COMMIT'); 166 | } 167 | 168 | /** 169 | * Rollback transaction 170 | */ 171 | async rollbackTransaction() { 172 | return await this.query('ROLLBACK'); 173 | } 174 | 175 | /** 176 | * Close database connection 177 | */ 178 | async close() { 179 | if (this.pool) { 180 | await this.pool.end(); 181 | console.log('Database connection closed'); 182 | } 183 | } 184 | } 185 | 186 | module.exports = DbHelper; 187 | -------------------------------------------------------------------------------- /tests/mobile/mobile.spec.js: -------------------------------------------------------------------------------- 1 | const { test, expect, devices } = require("@playwright/test"); 2 | const LoginPage = require("../../pages/LoginPage"); 3 | 4 | // Mobile test configurations 5 | const mobileDevices = [ 6 | devices["iPhone 12"], 7 | devices["iPad Pro"], 8 | devices["Pixel 5"], 9 | ]; 10 | 11 | test.describe("Mobile Tests", () => { 12 | test("should display mobile-friendly login page", async ({ page }) => { 13 | const loginPage = new LoginPage(page); 14 | await page.goto("https://www.saucedemo.com/"); 15 | 16 | // Verify mobile viewport 17 | const viewport = page.viewportSize(); 18 | expect(viewport.width).toBeLessThanOrEqual(768); 19 | 20 | // Check if login elements are visible and accessible 21 | await expect(loginPage.usernameInput).toBeVisible(); 22 | await expect(loginPage.passwordInput).toBeVisible(); 23 | await expect(loginPage.loginButton).toBeVisible(); 24 | 25 | // Test touch interactions 26 | await loginPage.usernameInput.tap(); 27 | await page.keyboard.type("standard_user"); 28 | 29 | await loginPage.passwordInput.tap(); 30 | await page.keyboard.type("secret_sauce"); 31 | 32 | await loginPage.loginButton.tap(); 33 | 34 | // Verify successful login on mobile 35 | await expect(page.locator(".title")).toContainText("Products"); 36 | }); 37 | 38 | test("should handle mobile navigation", async ({ page }) => { 39 | await page.goto("/"); 40 | 41 | // Login first 42 | await page.fill('[data-test="username"]', "standard_user"); 43 | await page.fill('[data-test="password"]', "secret_sauce"); 44 | await page.click('[data-test="login-button"]'); 45 | 46 | // Test mobile menu if present 47 | const menuButton = page.locator(".bm-burger-button"); 48 | if (await menuButton.isVisible()) { 49 | await menuButton.tap(); 50 | 51 | // Check if menu items are visible 52 | await expect(page.locator(".bm-menu")).toBeVisible(); 53 | 54 | // Test menu item interaction 55 | const logoutLink = page.locator("#logout_sidebar_link"); 56 | if (await logoutLink.isVisible()) { 57 | await logoutLink.tap(); 58 | await expect(page.locator('[data-test="login-button"]')).toBeVisible(); 59 | } 60 | } 61 | }); 62 | 63 | test("should display products in mobile layout", async ({ page }) => { 64 | await page.goto("/"); 65 | 66 | // Login 67 | await page.fill('[data-test="username"]', "standard_user"); 68 | await page.fill('[data-test="password"]', "secret_sauce"); 69 | await page.click('[data-test="login-button"]'); 70 | 71 | // Check product grid adapts to mobile 72 | const products = page.locator(".inventory_item"); 73 | await expect(products.first()).toBeVisible(); 74 | 75 | // Verify product cards are properly sized for mobile 76 | const firstProduct = products.first(); 77 | const boundingBox = await firstProduct.boundingBox(); 78 | 79 | expect(boundingBox.width).toBeLessThanOrEqual(viewport.width); 80 | expect(boundingBox.width).toBeGreaterThan(0); 81 | }); 82 | 83 | test("should handle mobile cart interactions", async ({ page }) => { 84 | await page.goto("/"); 85 | 86 | // Login 87 | await page.fill('[data-test="username"]', "standard_user"); 88 | await page.fill('[data-test="password"]', "secret_sauce"); 89 | await page.click('[data-test="login-button"]'); 90 | 91 | // Add item to cart using tap 92 | const addToCartButton = page.locator( 93 | '[data-test="add-to-cart-sauce-labs-backpack"]' 94 | ); 95 | await addToCartButton.tap(); 96 | 97 | // Verify cart badge updates 98 | const cartBadge = page.locator(".shopping_cart_badge"); 99 | await expect(cartBadge).toContainText("1"); 100 | 101 | // Go to cart 102 | await page.locator(".shopping_cart_link").tap(); 103 | 104 | // Verify cart page on mobile 105 | await expect(page.locator(".cart_item")).toBeVisible(); 106 | 107 | // Test quantity interactions on mobile 108 | const removeButton = page.locator( 109 | '[data-test="remove-sauce-labs-backpack"]' 110 | ); 111 | await removeButton.tap(); 112 | 113 | // Verify item removed 114 | await expect(page.locator(".cart_item")).not.toBeVisible(); 115 | }); 116 | 117 | test("should handle mobile checkout flow", async ({ page }) => { 118 | await page.goto("/"); 119 | 120 | // Login and add item 121 | await page.fill('[data-test="username"]', "standard_user"); 122 | await page.fill('[data-test="password"]', "secret_sauce"); 123 | await page.click('[data-test="login-button"]'); 124 | 125 | await page.locator('[data-test="add-to-cart-sauce-labs-backpack"]').tap(); 126 | await page.locator(".shopping_cart_link").tap(); 127 | 128 | // Proceed to checkout 129 | await page.locator('[data-test="checkout"]').tap(); 130 | 131 | // Fill mobile form - test virtual keyboard interactions 132 | await page.locator('[data-test="firstName"]').tap(); 133 | await page.keyboard.type("John"); 134 | 135 | await page.locator('[data-test="lastName"]').tap(); 136 | await page.keyboard.type("Doe"); 137 | 138 | await page.locator('[data-test="postalCode"]').tap(); 139 | await page.keyboard.type("12345"); 140 | 141 | // Continue checkout 142 | await page.locator('[data-test="continue"]').tap(); 143 | 144 | // Verify checkout overview on mobile 145 | await expect(page.locator(".summary_info")).toBeVisible(); 146 | 147 | // Complete purchase 148 | await page.locator('[data-test="finish"]').tap(); 149 | 150 | // Verify success on mobile 151 | await expect(page.locator(".complete-header")).toContainText( 152 | "Thank you for your order!" 153 | ); 154 | }); 155 | 156 | test("should handle mobile orientation changes", async ({ 157 | page, 158 | browser, 159 | }) => { 160 | // This test simulates orientation change 161 | await page.goto("/"); 162 | 163 | // Start in portrait mode 164 | await page.setViewportSize({ width: 375, height: 812 }); // iPhone portrait 165 | 166 | await page.fill('[data-test="username"]', "standard_user"); 167 | await page.fill('[data-test="password"]', "secret_sauce"); 168 | await page.click('[data-test="login-button"]'); 169 | 170 | // Verify layout in portrait 171 | await expect(page.locator(".inventory_list")).toBeVisible(); 172 | 173 | // Switch to landscape mode 174 | await page.setViewportSize({ width: 812, height: 375 }); // iPhone landscape 175 | 176 | // Verify layout adapts to landscape 177 | await expect(page.locator(".inventory_list")).toBeVisible(); 178 | 179 | // Check if products are still accessible 180 | const products = page.locator(".inventory_item"); 181 | await expect(products.first()).toBeVisible(); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /pages/HomePage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Home Page (Inventory) Page Object Model 3 | */ 4 | class HomePage { 5 | constructor(page) { 6 | this.page = page; 7 | 8 | // Selectors 9 | this.productsHeader = page.locator('.title'); 10 | this.inventoryList = page.locator('.inventory_list'); 11 | this.inventoryItems = page.locator('.inventory_item'); 12 | this.shoppingCartLink = page.locator('.shopping_cart_link'); 13 | this.shoppingCartBadge = page.locator('.shopping_cart_badge'); 14 | this.menuButton = page.locator('#react-burger-menu-btn'); 15 | this.sidebar = page.locator('.bm-menu'); 16 | this.sortDropdown = page.locator('.product_sort_container'); 17 | this.appLogo = page.locator('.app_logo'); 18 | } 19 | 20 | /** 21 | * Navigate to home/inventory page 22 | */ 23 | async goto() { 24 | await this.page.goto('/inventory.html'); 25 | } 26 | 27 | /** 28 | * Get all products on the page 29 | * @returns {Promise} Array of product elements 30 | */ 31 | async getAllProducts() { 32 | return await this.inventoryItems.all(); 33 | } 34 | 35 | /** 36 | * Get product by name 37 | * @param {string} productName - Product name 38 | * @returns {Promise} Product element 39 | */ 40 | getProductByName(productName) { 41 | return this.page.locator(`.inventory_item:has-text("${productName}")`); 42 | } 43 | 44 | /** 45 | * Add product to cart by test id 46 | * @param {string} productId - Product test ID 47 | */ 48 | async addProductToCart(productId) { 49 | await this.page.locator(`[data-test="add-to-cart-${productId}"]`).click(); 50 | } 51 | 52 | /** 53 | * Remove product from cart by test id 54 | * @param {string} productId - Product test ID 55 | */ 56 | async removeProductFromCart(productId) { 57 | await this.page.locator(`[data-test="remove-${productId}"]`).click(); 58 | } 59 | 60 | /** 61 | * Go to shopping cart 62 | */ 63 | async goToCart() { 64 | await this.shoppingCartLink.click(); 65 | } 66 | 67 | /** 68 | * Get cart item count 69 | * @returns {Promise} Number of items in cart 70 | */ 71 | async getCartItemCount() { 72 | if (await this.shoppingCartBadge.isVisible()) { 73 | const badgeText = await this.shoppingCartBadge.textContent(); 74 | return parseInt(badgeText) || 0; 75 | } 76 | return 0; 77 | } 78 | 79 | /** 80 | * Open menu sidebar 81 | */ 82 | async openMenu() { 83 | await this.menuButton.click(); 84 | } 85 | 86 | /** 87 | * Close menu sidebar 88 | */ 89 | async closeMenu() { 90 | const closeButton = this.page.locator('#react-burger-cross-btn'); 91 | await closeButton.click(); 92 | } 93 | 94 | /** 95 | * Logout from the application 96 | */ 97 | async logout() { 98 | await this.openMenu(); 99 | await this.page.locator('#logout_sidebar_link').click(); 100 | } 101 | 102 | /** 103 | * Reset app state 104 | */ 105 | async resetAppState() { 106 | await this.openMenu(); 107 | await this.page.locator('#reset_sidebar_link').click(); 108 | await this.closeMenu(); 109 | } 110 | 111 | /** 112 | * Sort products 113 | * @param {string} sortOption - Sort option ('az', 'za', 'lohi', 'hilo') 114 | */ 115 | async sortProducts(sortOption) { 116 | await this.sortDropdown.selectOption(sortOption); 117 | } 118 | 119 | /** 120 | * Get all product names 121 | * @returns {Promise} Array of product names 122 | */ 123 | async getAllProductNames() { 124 | const nameElements = await this.page.locator('.inventory_item_name').all(); 125 | const names = []; 126 | for (const element of nameElements) { 127 | names.push(await element.textContent()); 128 | } 129 | return names; 130 | } 131 | 132 | /** 133 | * Get all product prices 134 | * @returns {Promise} Array of product prices 135 | */ 136 | async getAllProductPrices() { 137 | const priceElements = await this.page.locator('.inventory_item_price').all(); 138 | const prices = []; 139 | for (const element of priceElements) { 140 | const priceText = await element.textContent(); 141 | const price = parseFloat(priceText.replace('$', '')); 142 | prices.push(price); 143 | } 144 | return prices; 145 | } 146 | 147 | /** 148 | * Click on product name to view details 149 | * @param {string} productName - Product name 150 | */ 151 | async clickProductName(productName) { 152 | await this.page.locator(`.inventory_item_name:has-text("${productName}")`).click(); 153 | } 154 | 155 | /** 156 | * Click on product image to view details 157 | * @param {string} productName - Product name 158 | */ 159 | async clickProductImage(productName) { 160 | const product = this.getProductByName(productName); 161 | await product.locator('.inventory_item_img').click(); 162 | } 163 | 164 | /** 165 | * Get product description 166 | * @param {string} productName - Product name 167 | * @returns {Promise} Product description 168 | */ 169 | async getProductDescription(productName) { 170 | const product = this.getProductByName(productName); 171 | return await product.locator('.inventory_item_desc').textContent(); 172 | } 173 | 174 | /** 175 | * Get product price 176 | * @param {string} productName - Product name 177 | * @returns {Promise} Product price 178 | */ 179 | async getProductPrice(productName) { 180 | const product = this.getProductByName(productName); 181 | const priceText = await product.locator('.inventory_item_price').textContent(); 182 | return parseFloat(priceText.replace('$', '')); 183 | } 184 | 185 | /** 186 | * Check if product is in cart 187 | * @param {string} productId - Product test ID 188 | * @returns {Promise} True if product is in cart 189 | */ 190 | async isProductInCart(productId) { 191 | const removeButton = this.page.locator(`[data-test="remove-${productId}"]`); 192 | return await removeButton.isVisible(); 193 | } 194 | 195 | /** 196 | * Get number of visible products 197 | * @returns {Promise} Number of products 198 | */ 199 | async getProductCount() { 200 | return await this.inventoryItems.count(); 201 | } 202 | 203 | /** 204 | * Wait for products to load 205 | */ 206 | async waitForProductsToLoad() { 207 | await this.inventoryList.waitFor({ state: 'visible' }); 208 | await this.inventoryItems.first().waitFor({ state: 'visible' }); 209 | } 210 | 211 | /** 212 | * Check if page is loaded 213 | * @returns {Promise} True if page is loaded 214 | */ 215 | async isPageLoaded() { 216 | try { 217 | await this.productsHeader.waitFor({ state: 'visible', timeout: 5000 }); 218 | return true; 219 | } catch { 220 | return false; 221 | } 222 | } 223 | } 224 | 225 | module.exports = HomePage; 226 | -------------------------------------------------------------------------------- /pages/CartPage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Cart Page Object Model 3 | */ 4 | class CartPage { 5 | constructor(page) { 6 | this.page = page; 7 | 8 | // Selectors 9 | this.pageTitle = page.locator('.title'); 10 | this.cartItems = page.locator('.cart_item'); 11 | this.cartItemNames = page.locator('.inventory_item_name'); 12 | this.cartItemPrices = page.locator('.inventory_item_price'); 13 | this.cartItemQuantities = page.locator('.cart_quantity'); 14 | this.continueShoppingButton = page.locator('[data-test="continue-shopping"]'); 15 | this.checkoutButton = page.locator('[data-test="checkout"]'); 16 | this.cartBadge = page.locator('.shopping_cart_badge'); 17 | this.cartList = page.locator('.cart_list'); 18 | } 19 | 20 | /** 21 | * Navigate to cart page 22 | */ 23 | async goto() { 24 | await this.page.goto('/cart.html'); 25 | } 26 | 27 | /** 28 | * Get all cart items 29 | * @returns {Promise} Array of cart item elements 30 | */ 31 | async getAllCartItems() { 32 | return await this.cartItems.all(); 33 | } 34 | 35 | /** 36 | * Get cart item by name 37 | * @param {string} itemName - Item name 38 | * @returns {Promise} Cart item element 39 | */ 40 | getCartItemByName(itemName) { 41 | return this.page.locator(`.cart_item:has-text("${itemName}")`); 42 | } 43 | 44 | /** 45 | * Remove item from cart 46 | * @param {string} productId - Product test ID 47 | */ 48 | async removeItem(productId) { 49 | await this.page.locator(`[data-test="remove-${productId}"]`).click(); 50 | } 51 | 52 | /** 53 | * Continue shopping 54 | */ 55 | async continueShopping() { 56 | await this.continueShoppingButton.click(); 57 | } 58 | 59 | /** 60 | * Proceed to checkout 61 | */ 62 | async proceedToCheckout() { 63 | await this.checkoutButton.click(); 64 | } 65 | 66 | /** 67 | * Get cart item count 68 | * @returns {Promise} Number of items in cart 69 | */ 70 | async getCartItemCount() { 71 | return await this.cartItems.count(); 72 | } 73 | 74 | /** 75 | * Check if cart is empty 76 | * @returns {Promise} True if cart is empty 77 | */ 78 | async isCartEmpty() { 79 | const itemCount = await this.getCartItemCount(); 80 | return itemCount === 0; 81 | } 82 | 83 | /** 84 | * Get all item names in cart 85 | * @returns {Promise} Array of item names 86 | */ 87 | async getAllItemNames() { 88 | const nameElements = await this.cartItemNames.all(); 89 | const names = []; 90 | for (const element of nameElements) { 91 | names.push(await element.textContent()); 92 | } 93 | return names; 94 | } 95 | 96 | /** 97 | * Get all item prices in cart 98 | * @returns {Promise} Array of item prices 99 | */ 100 | async getItemPrices() { 101 | const priceElements = await this.cartItemPrices.all(); 102 | const prices = []; 103 | for (const element of priceElements) { 104 | const priceText = await element.textContent(); 105 | const price = parseFloat(priceText.replace('$', '')); 106 | prices.push(price); 107 | } 108 | return prices; 109 | } 110 | 111 | /** 112 | * Get all item quantities in cart 113 | * @returns {Promise} Array of item quantities 114 | */ 115 | async getAllItemQuantities() { 116 | const quantityElements = await this.cartItemQuantities.all(); 117 | const quantities = []; 118 | for (const element of quantityElements) { 119 | const quantityText = await element.textContent(); 120 | quantities.push(parseInt(quantityText)); 121 | } 122 | return quantities; 123 | } 124 | 125 | /** 126 | * Get item quantity by name 127 | * @param {string} itemName - Item name 128 | * @returns {Promise} Item quantity 129 | */ 130 | async getItemQuantity(itemName) { 131 | const item = this.getCartItemByName(itemName); 132 | const quantityText = await item.locator('.cart_quantity').textContent(); 133 | return parseInt(quantityText); 134 | } 135 | 136 | /** 137 | * Get item price by name 138 | * @param {string} itemName - Item name 139 | * @returns {Promise} Item price 140 | */ 141 | async getItemPrice(itemName) { 142 | const item = this.getCartItemByName(itemName); 143 | const priceText = await item.locator('.inventory_item_price').textContent(); 144 | return parseFloat(priceText.replace('$', '')); 145 | } 146 | 147 | /** 148 | * Get item description by name 149 | * @param {string} itemName - Item name 150 | * @returns {Promise} Item description 151 | */ 152 | async getItemDescription(itemName) { 153 | const item = this.getCartItemByName(itemName); 154 | return await item.locator('.inventory_item_desc').textContent(); 155 | } 156 | 157 | /** 158 | * Calculate total price of all items in cart 159 | * @returns {Promise} Total price 160 | */ 161 | async calculateTotalPrice() { 162 | const prices = await this.getItemPrices(); 163 | const quantities = await this.getAllItemQuantities(); 164 | 165 | let total = 0; 166 | for (let i = 0; i < prices.length; i++) { 167 | total += prices[i] * quantities[i]; 168 | } 169 | 170 | return total; 171 | } 172 | 173 | /** 174 | * Check if specific item exists in cart 175 | * @param {string} itemName - Item name 176 | * @returns {Promise} True if item exists 177 | */ 178 | async hasItem(itemName) { 179 | const item = this.getCartItemByName(itemName); 180 | return await item.isVisible(); 181 | } 182 | 183 | /** 184 | * Remove all items from cart 185 | */ 186 | async removeAllItems() { 187 | const items = await this.getAllCartItems(); 188 | 189 | for (let i = 0; i < items.length; i++) { 190 | const removeButtons = await this.page.locator('[data-test*="remove-"]').all(); 191 | if (removeButtons.length > 0) { 192 | await removeButtons[0].click(); 193 | } 194 | } 195 | } 196 | 197 | /** 198 | * Get cart badge count 199 | * @returns {Promise} Badge count 200 | */ 201 | async getCartBadgeCount() { 202 | if (await this.cartBadge.isVisible()) { 203 | const badgeText = await this.cartBadge.textContent(); 204 | return parseInt(badgeText) || 0; 205 | } 206 | return 0; 207 | } 208 | 209 | /** 210 | * Wait for cart page to load 211 | */ 212 | async waitForLoad() { 213 | await this.pageTitle.waitFor({ state: 'visible' }); 214 | await this.cartList.waitFor({ state: 'visible' }); 215 | } 216 | 217 | /** 218 | * Check if checkout button is enabled 219 | * @returns {Promise} True if enabled 220 | */ 221 | async isCheckoutButtonEnabled() { 222 | return await this.checkoutButton.isEnabled(); 223 | } 224 | 225 | /** 226 | * Check if continue shopping button is visible 227 | * @returns {Promise} True if visible 228 | */ 229 | async isContinueShoppingButtonVisible() { 230 | return await this.continueShoppingButton.isVisible(); 231 | } 232 | } 233 | 234 | module.exports = CartPage; 235 | -------------------------------------------------------------------------------- /tests/fixtures/authFixtures.js: -------------------------------------------------------------------------------- 1 | const { test: base, expect } = require('@playwright/test'); 2 | const LoginPage = require('../../pages/LoginPage'); 3 | const HomePage = require('../../pages/HomePage'); 4 | 5 | /** 6 | * Authentication fixture that handles login for tests 7 | */ 8 | const authFixture = base.extend({ 9 | // Authenticated page fixture 10 | authenticatedPage: async ({ page }, use) => { 11 | const loginPage = new LoginPage(page); 12 | 13 | // Navigate to login page 14 | await page.goto('/'); 15 | 16 | // Perform login 17 | await loginPage.login( 18 | process.env.ADMIN_USERNAME || 'standard_user', 19 | process.env.ADMIN_PASSWORD || 'secret_sauce' 20 | ); 21 | 22 | // Verify successful login 23 | await expect(page.locator('.title')).toContainText('Products'); 24 | 25 | // Pass the authenticated page to the test 26 | await use(page); 27 | }, 28 | 29 | // Admin user fixture 30 | adminUser: async ({ page }, use) => { 31 | const adminCredentials = { 32 | username: process.env.ADMIN_USERNAME || 'standard_user', 33 | password: process.env.ADMIN_PASSWORD || 'secret_sauce' 34 | }; 35 | 36 | await use(adminCredentials); 37 | }, 38 | 39 | // Standard user fixture 40 | standardUser: async ({ page }, use) => { 41 | const userCredentials = { 42 | username: 'standard_user', 43 | password: 'secret_sauce' 44 | }; 45 | 46 | await use(userCredentials); 47 | }, 48 | 49 | // Problem user fixture (for testing edge cases) 50 | problemUser: async ({ page }, use) => { 51 | const userCredentials = { 52 | username: 'problem_user', 53 | password: 'secret_sauce' 54 | }; 55 | 56 | await use(userCredentials); 57 | }, 58 | 59 | // Performance glitch user fixture 60 | performanceGlitchUser: async ({ page }, use) => { 61 | const userCredentials = { 62 | username: 'performance_glitch_user', 63 | password: 'secret_sauce' 64 | }; 65 | 66 | await use(userCredentials); 67 | } 68 | }); 69 | 70 | /** 71 | * Cart fixture that provides cart functionality 72 | */ 73 | const cartFixture = authFixture.extend({ 74 | // Pre-filled cart fixture 75 | cartWithItems: async ({ authenticatedPage }, use) => { 76 | const homePage = new HomePage(authenticatedPage); 77 | 78 | // Add items to cart 79 | await homePage.addProductToCart('sauce-labs-backpack'); 80 | await homePage.addProductToCart('sauce-labs-bike-light'); 81 | 82 | // Verify items were added 83 | await expect(authenticatedPage.locator('.shopping_cart_badge')).toContainText('2'); 84 | 85 | await use(authenticatedPage); 86 | }, 87 | 88 | // Empty cart fixture 89 | emptyCart: async ({ authenticatedPage }, use) => { 90 | // Ensure cart is empty by removing all items if any exist 91 | const cartBadge = authenticatedPage.locator('.shopping_cart_badge'); 92 | if (await cartBadge.isVisible()) { 93 | await authenticatedPage.goto('/cart.html'); 94 | const removeButtons = authenticatedPage.locator('[data-test*="remove-"]'); 95 | const count = await removeButtons.count(); 96 | 97 | for (let i = 0; i < count; i++) { 98 | await removeButtons.nth(0).click(); 99 | } 100 | } 101 | 102 | await authenticatedPage.goto('/inventory.html'); 103 | await use(authenticatedPage); 104 | } 105 | }); 106 | 107 | /** 108 | * API fixture for API testing 109 | */ 110 | const apiFixture = base.extend({ 111 | // API context fixture 112 | apiContext: async ({ request }, use) => { 113 | // Set up API base URL and common headers 114 | const apiRequest = request; 115 | 116 | // Add authentication header if needed 117 | await apiRequest.newContext({ 118 | baseURL: process.env.API_BASE_URL || 'https://api.example.com', 119 | extraHTTPHeaders: { 120 | 'Content-Type': 'application/json', 121 | 'Accept': 'application/json' 122 | } 123 | }); 124 | 125 | await use(apiRequest); 126 | }, 127 | 128 | // Authenticated API context 129 | authenticatedApiContext: async ({ request }, use) => { 130 | // This would typically involve getting an auth token 131 | const apiRequest = request; 132 | 133 | // Mock authentication - replace with actual auth flow 134 | const authToken = 'mock-jwt-token'; 135 | 136 | await apiRequest.newContext({ 137 | baseURL: process.env.API_BASE_URL || 'https://api.example.com', 138 | extraHTTPHeaders: { 139 | 'Content-Type': 'application/json', 140 | 'Accept': 'application/json', 141 | 'Authorization': `Bearer ${authToken}` 142 | } 143 | }); 144 | 145 | await use(apiRequest); 146 | } 147 | }); 148 | 149 | /** 150 | * Database fixture for database testing 151 | */ 152 | const dbFixture = base.extend({ 153 | // Database connection fixture 154 | dbConnection: async ({}, use) => { 155 | const DbHelper = require('../../src/utils/dbHelper'); 156 | const db = new DbHelper(); 157 | 158 | // Connect to database 159 | await db.connect(); 160 | 161 | await use(db); 162 | 163 | // Cleanup: close database connection 164 | await db.close(); 165 | }, 166 | 167 | // Clean database fixture (starts with clean state) 168 | cleanDatabase: async ({}, use) => { 169 | const DbHelper = require('../../src/utils/dbHelper'); 170 | const db = new DbHelper(); 171 | 172 | await db.connect(); 173 | 174 | // Clean test data before test 175 | await db.cleanupTestData(['users', 'orders', 'products']); 176 | 177 | await use(db); 178 | 179 | // Clean test data after test 180 | await db.cleanupTestData(['users', 'orders', 'products']); 181 | await db.close(); 182 | } 183 | }); 184 | 185 | /** 186 | * Test data fixture 187 | */ 188 | const testDataFixture = base.extend({ 189 | // Test data generator fixture 190 | testData: async ({}, use) => { 191 | const TestDataGenerator = require('../../src/utils/testDataGenerator'); 192 | const generator = new TestDataGenerator(); 193 | 194 | await use(generator); 195 | }, 196 | 197 | // Pre-generated test users 198 | testUsers: async ({}, use) => { 199 | const TestDataGenerator = require('../../src/utils/testDataGenerator'); 200 | const generator = new TestDataGenerator(); 201 | 202 | const users = { 203 | admin: generator.generateUser({ 204 | email: 'admin@test.com', 205 | firstName: 'Admin', 206 | lastName: 'User', 207 | role: 'admin' 208 | }), 209 | customer: generator.generateUser({ 210 | email: 'customer@test.com', 211 | firstName: 'Customer', 212 | lastName: 'User', 213 | role: 'customer' 214 | }), 215 | guest: generator.generateUser({ 216 | email: 'guest@test.com', 217 | firstName: 'Guest', 218 | lastName: 'User', 219 | role: 'guest' 220 | }) 221 | }; 222 | 223 | await use(users); 224 | } 225 | }); 226 | 227 | // Combine all fixtures 228 | const test = base.extend({ 229 | ...authFixture, 230 | ...cartFixture, 231 | ...apiFixture, 232 | ...dbFixture, 233 | ...testDataFixture 234 | }); 235 | 236 | module.exports = { test, expect }; 237 | -------------------------------------------------------------------------------- /src/utils/testDataGenerator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test Data Generator utilities for creating test data 3 | */ 4 | const { faker } = require('@faker-js/faker'); 5 | 6 | class TestDataGenerator { 7 | constructor() { 8 | this.faker = faker; 9 | } 10 | 11 | /** 12 | * Generate user data 13 | * @param {Object} overrides - Override specific fields 14 | * @returns {Object} User data 15 | */ 16 | generateUser(overrides = {}) { 17 | const defaultUser = { 18 | firstName: this.faker.person.firstName(), 19 | lastName: this.faker.person.lastName(), 20 | email: this.faker.internet.email(), 21 | username: this.faker.internet.userName(), 22 | password: 'Test@123', 23 | phone: this.faker.phone.number(), 24 | address: { 25 | street: this.faker.location.streetAddress(), 26 | city: this.faker.location.city(), 27 | state: this.faker.location.state(), 28 | zipCode: this.faker.location.zipCode(), 29 | country: this.faker.location.country() 30 | }, 31 | dateOfBirth: this.faker.date.past({ years: 30, refDate: new Date('2000-01-01') }), 32 | avatar: this.faker.image.avatar() 33 | }; 34 | 35 | return { ...defaultUser, ...overrides }; 36 | } 37 | 38 | /** 39 | * Generate product data 40 | * @param {Object} overrides - Override specific fields 41 | * @returns {Object} Product data 42 | */ 43 | generateProduct(overrides = {}) { 44 | const defaultProduct = { 45 | name: this.faker.commerce.productName(), 46 | description: this.faker.commerce.productDescription(), 47 | price: parseFloat(this.faker.commerce.price({ min: 10, max: 1000 })), 48 | category: this.faker.commerce.department(), 49 | brand: this.faker.company.name(), 50 | sku: this.faker.string.alphanumeric(8).toUpperCase(), 51 | stock: this.faker.number.int({ min: 0, max: 100 }), 52 | image: this.faker.image.url({ width: 400, height: 400 }), 53 | tags: this.faker.helpers.arrayElements([ 54 | 'electronics', 'clothing', 'books', 'home', 'sports', 'toys' 55 | ], { min: 1, max: 3 }) 56 | }; 57 | 58 | return { ...defaultProduct, ...overrides }; 59 | } 60 | 61 | /** 62 | * Generate order data 63 | * @param {Object} overrides - Override specific fields 64 | * @returns {Object} Order data 65 | */ 66 | generateOrder(overrides = {}) { 67 | const items = this.faker.helpers.arrayElements( 68 | Array.from({ length: 5 }, () => this.generateProduct()), 69 | { min: 1, max: 3 } 70 | ).map(product => ({ 71 | productId: product.sku, 72 | productName: product.name, 73 | quantity: this.faker.number.int({ min: 1, max: 5 }), 74 | unitPrice: product.price, 75 | totalPrice: product.price * this.faker.number.int({ min: 1, max: 5 }) 76 | })); 77 | 78 | const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0); 79 | const tax = subtotal * 0.08; 80 | const shipping = this.faker.number.float({ min: 5, max: 25, fractionDigits: 2 }); 81 | 82 | const defaultOrder = { 83 | orderId: this.faker.string.uuid(), 84 | orderNumber: this.faker.string.alphanumeric(10).toUpperCase(), 85 | customer: this.generateUser(), 86 | items: items, 87 | subtotal: parseFloat(subtotal.toFixed(2)), 88 | tax: parseFloat(tax.toFixed(2)), 89 | shipping: shipping, 90 | total: parseFloat((subtotal + tax + shipping).toFixed(2)), 91 | status: this.faker.helpers.arrayElement(['pending', 'processing', 'shipped', 'delivered']), 92 | orderDate: this.faker.date.recent({ days: 30 }), 93 | shippingAddress: { 94 | street: this.faker.location.streetAddress(), 95 | city: this.faker.location.city(), 96 | state: this.faker.location.state(), 97 | zipCode: this.faker.location.zipCode(), 98 | country: this.faker.location.country() 99 | } 100 | }; 101 | 102 | return { ...defaultOrder, ...overrides }; 103 | } 104 | 105 | /** 106 | * Generate company data 107 | * @param {Object} overrides - Override specific fields 108 | * @returns {Object} Company data 109 | */ 110 | generateCompany(overrides = {}) { 111 | const defaultCompany = { 112 | name: this.faker.company.name(), 113 | industry: this.faker.company.buzzNoun(), 114 | description: this.faker.company.catchPhrase(), 115 | website: this.faker.internet.url(), 116 | email: this.faker.internet.email(), 117 | phone: this.faker.phone.number(), 118 | address: { 119 | street: this.faker.location.streetAddress(), 120 | city: this.faker.location.city(), 121 | state: this.faker.location.state(), 122 | zipCode: this.faker.location.zipCode(), 123 | country: this.faker.location.country() 124 | }, 125 | employees: this.faker.number.int({ min: 10, max: 10000 }), 126 | founded: this.faker.date.past({ years: 50 }), 127 | logo: this.faker.image.url({ width: 200, height: 200 }) 128 | }; 129 | 130 | return { ...defaultCompany, ...overrides }; 131 | } 132 | 133 | /** 134 | * Generate credit card data 135 | * @param {Object} overrides - Override specific fields 136 | * @returns {Object} Credit card data 137 | */ 138 | generateCreditCard(overrides = {}) { 139 | const defaultCard = { 140 | number: this.faker.finance.creditCardNumber(), 141 | cvv: this.faker.finance.creditCardCVV(), 142 | expiryMonth: this.faker.date.future().getMonth() + 1, 143 | expiryYear: this.faker.date.future().getFullYear(), 144 | holderName: this.faker.person.fullName(), 145 | type: this.faker.helpers.arrayElement(['Visa', 'Mastercard', 'American Express']) 146 | }; 147 | 148 | return { ...defaultCard, ...overrides }; 149 | } 150 | 151 | /** 152 | * Generate random string 153 | * @param {number} length - String length 154 | * @param {string} type - Type of string (alpha, numeric, alphanumeric) 155 | * @returns {string} Random string 156 | */ 157 | generateRandomString(length = 10, type = 'alphanumeric') { 158 | switch (type) { 159 | case 'alpha': 160 | return this.faker.string.alpha(length); 161 | case 'numeric': 162 | return this.faker.string.numeric(length); 163 | case 'alphanumeric': 164 | default: 165 | return this.faker.string.alphanumeric(length); 166 | } 167 | } 168 | 169 | /** 170 | * Generate random date 171 | * @param {string} type - Type of date (past, future, recent) 172 | * @param {Object} options - Date options 173 | * @returns {Date} Random date 174 | */ 175 | generateRandomDate(type = 'recent', options = {}) { 176 | switch (type) { 177 | case 'past': 178 | return this.faker.date.past(options); 179 | case 'future': 180 | return this.faker.date.future(options); 181 | case 'recent': 182 | default: 183 | return this.faker.date.recent(options); 184 | } 185 | } 186 | 187 | /** 188 | * Generate test data from array 189 | * @param {Array} items - Array of possible values 190 | * @param {number} count - Number of items to select 191 | * @returns {Array} Selected items 192 | */ 193 | generateFromArray(items, count = 1) { 194 | return this.faker.helpers.arrayElements(items, count); 195 | } 196 | 197 | /** 198 | * Generate multiple items of the same type 199 | * @param {Function} generator - Generator function 200 | * @param {number} count - Number of items to generate 201 | * @param {Object} overrides - Override options 202 | * @returns {Array} Array of generated items 203 | */ 204 | generateMultiple(generator, count = 5, overrides = {}) { 205 | return Array.from({ length: count }, () => generator.call(this, overrides)); 206 | } 207 | } 208 | 209 | module.exports = TestDataGenerator; 210 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Playwright End-to-End (E2E) Test Automation Framework 2 | 3 | ## 🚀 Overview 4 | 5 | This repository presents a **robust, scalable, and enterprise-ready End-to-End Test Automation Framework** built on [Playwright](https://playwright.dev/). 6 | 7 | Designed for **modern web and mobile applications**, it supports: 8 | - 🌐 Cross-browser testing 9 | - 🔌 API validation 10 | - 🔄 CI/CD integration 11 | - 📊 Data-driven test execution 12 | 13 | Whether you're validating a checkout flow, simulating mobile interactions, or integrating with backend APIs, this framework delivers **speed, reliability, and maintainability**. 14 | 15 | --- 16 | 17 | ## 📁 Folder Structure 18 | 19 | ```bash 20 | .github/ 21 | ├─ workflows/ # GitHub Actions workflows 22 | │ ├─ performance.yml 23 | │ ├─ playwright.yml 24 | │ └─ security.yml 25 | 26 | config/ # Environment-specific configs 27 | ├─ dev.env.js 28 | ├─ qa.env.js 29 | ├─ staging.env.js 30 | └─ prod.env.js 31 | 32 | data/ # Test data sources 33 | ├─ credentials/ 34 | │ └─ credentials.enc.json 35 | ├─ products.csv 36 | └─ users.json 37 | 38 | src/ 39 | ├─ pages/ # Page Object Models (POM) 40 | │ ├─ HomePage.ts 41 | │ ├─ LoginPage.ts 42 | │ ├─ CartPage.ts 43 | │ └─ CheckoutPage.ts 44 | ├─ tests/ # Organized test suites 45 | │ ├─ e2e/ 46 | │ │ ├─ login.spec.ts 47 | │ │ ├─ checkout.spec.ts 48 | │ │ └─ payment.spec.ts 49 | │ ├─ api/ 50 | │ │ └─ users.api.spec.ts 51 | │ └─ mobile/ 52 | │ └─ mobile.spec.ts 53 | ├─ fixtures/ 54 | │ └─ authFixtures.ts 55 | └─ utils/ 56 | ├─ apiHelper.ts 57 | ├─ dbHelper.ts 58 | └─ testDataGenerator.ts 59 | 60 | tests-examples/ # Demo specs and sandbox tests 61 | ├─ demo-todo-app.spec.ts 62 | ├─ example.spec.ts 63 | └─ sauceLabsDemo.spec.ts 64 | 65 | reports/ # Reports and screenshots 66 | ├─ html-report/ 67 | │ └─ index.html 68 | ├─ junit-results.xml 69 | └─ screenshots/ 70 | 71 | playwright.config.ts # Playwright configuration 72 | package.json # Project dependencies 73 | tsconfig.json # TypeScript config 74 | jsconfig.json # JS tooling config 75 | Jenkinsfile # CI pipeline config 76 | .env # Environment variables 77 | .gitignore # Git exclusions 78 | README.md # Project documentation 79 | ```` 80 | 81 | --- 82 | 83 | ## 🧰 Features 84 | 85 | * ✅ **Cross-browser support** → Chromium, Firefox, WebKit 86 | * 📱 **Device emulation** → Test responsive & mobile flows 87 | * 🔐 **Secure credential handling** → `.env` + encrypted data 88 | * 🧪 **UI + API testing** in one framework 89 | * 🧩 **Page Object Model (POM)** → Clean abstractions 90 | * 📊 **Reports** → HTML, JUnit XML, Screenshots 91 | * 🔄 **CI/CD ready** → Jenkins & GitHub Actions support 92 | * 🧬 **Data-driven execution** → CSV, JSON, JS datasets 93 | * 🧱 **Fixtures & mocks** → Modular test scaffolding 94 | 95 | --- 96 | 97 | ## 🧨 Pain Points Solved 98 | 99 | | ❌ Common Challenge | ✅ Framework Solution | 100 | | ----------------------------- | -------------------------------------- | 101 | | Flaky & unreliable UI tests | Auto-waiting, smart locators, retries | 102 | | Manual regression bottlenecks | Automated CI-driven test execution | 103 | | High test maintenance cost | Modular POM + utils architecture | 104 | | Config/environment drift | Config-driven (`dev`, `qa`, `staging`) | 105 | | Limited API test coverage | Built-in API test support | 106 | | CI/CD slowdowns | Optimized pipelines + parallel tests | 107 | 108 | --- 109 | 110 | ## 💼 Business Impact (Context-Driven Perspective) 111 | 112 | This framework isn’t a silver bullet. Test automation never is. What it does provide is a **scaffolding for exploration, risk discovery, and repeatability**. 113 | 114 | Instead of treating automation as a substitute for testing, this framework positions it as a **tool for investigation and learning**: 115 | 116 | - 🔍 **Faster feedback, not false confidence** 117 | Automated checks can detect certain regressions quickly. They don’t “prove quality,” but they help free human testers to explore risks that automation can’t see. 118 | 119 | - ⚖️ **Balance between speed and depth** 120 | It accelerates repetitive flows (logins, checkouts, API contracts) so testers can invest time in probing for edge cases, usability issues, and emergent behaviors. 121 | 122 | - 🧩 **System awareness, not just scripts** 123 | With API + UI + config-driven tests, it builds a model of the system’s moving parts, highlighting where failures cluster, where environments drift, and where risk lives. 124 | 125 | - 🤝 **Supports collaboration across roles** 126 | Developers can use it to guard against unintended breakage. Testers can use it to amplify their reach. Business stakeholders can see reports that reveal patterns, not just pass/fail counts. 127 | 128 | - 🧠 **Encourages critical thinking** 129 | By making tests modular and explicit, it invites questioning: *Why are we testing this? What risks are we not covering? What signals do we actually need from automation?* 130 | 131 | --- 132 | 133 | ## 📣 Market Positioning (Not Just for Show) 134 | 135 | This framework is not about “impressing with green checks.” It’s about creating **a practical, extensible platform** that teams can adapt to their unique context: 136 | 137 | - 🚀 **For lean startups** → Provides scaffolding to get quick checks in place, but leaves room for exploratory testers to chase the unexpected. 138 | 139 | - 🏢 **For enterprises** → Offers a structure that can scale across teams without forcing uniformity—teams can adapt pages, fixtures, and data sets as risks evolve. 140 | 141 | - ⚡ **For Agile/DevOps pipelines** → Acts as a living, lightweight probe in CI/CD. It tells you where the product might be drifting, but doesn’t pretend to guarantee “release readiness.” 142 | 143 | - 🛒 **For SaaS / high-change environments** → Gives rapid regression coverage for critical flows, but also creates a foundation where exploratory charters can be layered on top. 144 | 145 | --- 146 | 147 | > **Why this matters:** 148 | > Too often, automation is sold as certainty. This framework avoids that trap. It’s a tool to help **reduce some risks, reveal others, and accelerate learning**. It doesn’t replace human testers—it empowers them. 149 | 150 | --- 151 | 152 | ## 🏁 Getting Started 153 | 154 | ```bash 155 | # Install dependencies 156 | npm init playwright@latest 157 | 158 | # Run all tests 159 | npx playwright test 160 | 161 | # Run in UI mode 162 | npx playwright test --ui 163 | 164 | # Run a specific test 165 | npx playwright test src/tests/e2e/login.spec.ts 166 | 167 | # Generate HTML report 168 | npx playwright show-report 169 | 170 | # Run with environment config 171 | cross-env ENV=qa npx playwright test 172 | 173 | # Allure-Reporter Installation 174 | npm install --save-dev @playwright/test allure-playwright 175 | 176 | # Run Allure to convert the test results into an HTML report 177 | allure serve allure-results 178 | ``` 179 | 180 | --- 181 | 182 | ## 🧪 Example Test 183 | 184 | ```ts 185 | test('User can login and checkout', async ({ page }) => { 186 | const loginPage = new LoginPage(page); 187 | await loginPage.login('user@example.com', 'securePassword'); 188 | 189 | const cartPage = new CartPage(page); 190 | await cartPage.addProductToCart('Product123'); 191 | 192 | const checkoutPage = new CheckoutPage(page); 193 | await checkoutPage.completeCheckout(); 194 | }); 195 | ``` 196 | 197 | --- 198 | 199 | ## 🤝 Contributing 200 | 201 | Contributions are welcome! 202 | 203 | * Fork the repo 204 | * Create a feature branch 205 | * Submit a PR 206 | 207 | For significant changes, open an issue first to discuss your ideas. 208 | 209 | --- 210 | 211 | ## 📬 Contact 212 | 213 | 👤 Created by [Rishikesh Vajre](https://www.linkedin.com/in/rishikesh-vajre/) 214 | 💬 Reach out via GitHub Issues or LinkedIn for discussions, feedback and collaborations. 215 | 216 | ``` 217 | 218 | --- -------------------------------------------------------------------------------- /tests/api/users.api.spec.js: -------------------------------------------------------------------------------- 1 | const { test, expect } = require('@playwright/test'); 2 | const ApiHelper = require('../../src/utils/apiHelper'); 3 | 4 | test.describe('Users API Tests', () => { 5 | let apiHelper; 6 | 7 | test.beforeAll(async () => { 8 | apiHelper = new ApiHelper(); 9 | }); 10 | 11 | test.describe('GET /api/users', () => { 12 | test('should get all users', async () => { 13 | // Mock API endpoint - replace with actual API 14 | const mockUsers = [ 15 | { id: 1, name: 'John Doe', email: 'john@example.com' }, 16 | { id: 2, name: 'Jane Smith', email: 'jane@example.com' } 17 | ]; 18 | 19 | // For actual API testing, you would do: 20 | // const response = await apiHelper.get('/users'); 21 | // expect(response).toBeDefined(); 22 | // expect(Array.isArray(response.data)).toBe(true); 23 | 24 | // Mock test for demonstration 25 | expect(mockUsers).toBeDefined(); 26 | expect(Array.isArray(mockUsers)).toBe(true); 27 | expect(mockUsers.length).toBeGreaterThan(0); 28 | }); 29 | 30 | test('should get user by id', async () => { 31 | const userId = 1; 32 | 33 | // Mock API response 34 | const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' }; 35 | 36 | // For actual API testing: 37 | // const response = await apiHelper.get(`/users/${userId}`); 38 | // expect(response.id).toBe(userId); 39 | // expect(response.name).toBeDefined(); 40 | // expect(response.email).toBeDefined(); 41 | 42 | // Mock test 43 | expect(mockUser.id).toBe(userId); 44 | expect(mockUser.name).toBeDefined(); 45 | expect(mockUser.email).toBeDefined(); 46 | }); 47 | 48 | test('should return 404 for non-existent user', async () => { 49 | const nonExistentUserId = 999; 50 | 51 | // For actual API testing: 52 | // try { 53 | // await apiHelper.get(`/users/${nonExistentUserId}`); 54 | // expect(true).toBe(false); // Should not reach here 55 | // } catch (error) { 56 | // expect(error.message).toContain('404'); 57 | // } 58 | 59 | // Mock test 60 | const mockError = { status: 404, message: 'User not found' }; 61 | expect(mockError.status).toBe(404); 62 | expect(mockError.message).toBe('User not found'); 63 | }); 64 | }); 65 | 66 | test.describe('POST /api/users', () => { 67 | test('should create new user', async () => { 68 | const newUser = { 69 | name: 'Test User', 70 | email: 'testuser@example.com', 71 | password: 'password123' 72 | }; 73 | 74 | // For actual API testing: 75 | // const response = await apiHelper.post('/users', newUser); 76 | // expect(response.id).toBeDefined(); 77 | // expect(response.name).toBe(newUser.name); 78 | // expect(response.email).toBe(newUser.email); 79 | // expect(response.password).toBeUndefined(); // Password should not be returned 80 | 81 | // Mock test 82 | const mockResponse = { 83 | id: 123, 84 | name: newUser.name, 85 | email: newUser.email, 86 | createdAt: new Date().toISOString() 87 | }; 88 | 89 | expect(mockResponse.id).toBeDefined(); 90 | expect(mockResponse.name).toBe(newUser.name); 91 | expect(mockResponse.email).toBe(newUser.email); 92 | expect(mockResponse.createdAt).toBeDefined(); 93 | }); 94 | 95 | test('should validate required fields', async () => { 96 | const incompleteUser = { 97 | name: 'Test User' 98 | // Missing email and password 99 | }; 100 | 101 | // For actual API testing: 102 | // try { 103 | // await apiHelper.post('/users', incompleteUser); 104 | // expect(true).toBe(false); // Should not reach here 105 | // } catch (error) { 106 | // expect(error.message).toContain('400'); 107 | // } 108 | 109 | // Mock validation test 110 | const mockValidationError = { 111 | status: 400, 112 | errors: ['Email is required', 'Password is required'] 113 | }; 114 | 115 | expect(mockValidationError.status).toBe(400); 116 | expect(mockValidationError.errors).toContain('Email is required'); 117 | expect(mockValidationError.errors).toContain('Password is required'); 118 | }); 119 | 120 | test('should reject duplicate email', async () => { 121 | const existingUser = { 122 | name: 'Duplicate User', 123 | email: 'existing@example.com', 124 | password: 'password123' 125 | }; 126 | 127 | // For actual API testing: 128 | // try { 129 | // await apiHelper.post('/users', existingUser); 130 | // expect(true).toBe(false); // Should not reach here 131 | // } catch (error) { 132 | // expect(error.message).toContain('409'); 133 | // } 134 | 135 | // Mock test 136 | const mockConflictError = { 137 | status: 409, 138 | message: 'User with this email already exists' 139 | }; 140 | 141 | expect(mockConflictError.status).toBe(409); 142 | expect(mockConflictError.message).toBe('User with this email already exists'); 143 | }); 144 | }); 145 | 146 | test.describe('PUT /api/users/:id', () => { 147 | test('should update user', async () => { 148 | const userId = 1; 149 | const updateData = { 150 | name: 'Updated Name', 151 | email: 'updated@example.com' 152 | }; 153 | 154 | // For actual API testing: 155 | // const response = await apiHelper.put(`/users/${userId}`, updateData); 156 | // expect(response.id).toBe(userId); 157 | // expect(response.name).toBe(updateData.name); 158 | // expect(response.email).toBe(updateData.email); 159 | 160 | // Mock test 161 | const mockUpdatedUser = { 162 | id: userId, 163 | name: updateData.name, 164 | email: updateData.email, 165 | updatedAt: new Date().toISOString() 166 | }; 167 | 168 | expect(mockUpdatedUser.id).toBe(userId); 169 | expect(mockUpdatedUser.name).toBe(updateData.name); 170 | expect(mockUpdatedUser.email).toBe(updateData.email); 171 | expect(mockUpdatedUser.updatedAt).toBeDefined(); 172 | }); 173 | }); 174 | 175 | test.describe('DELETE /api/users/:id', () => { 176 | test('should delete user', async () => { 177 | const userId = 1; 178 | 179 | // For actual API testing: 180 | // const response = await apiHelper.delete(`/users/${userId}`); 181 | // expect(response.status).toBe(204); 182 | 183 | // Mock test 184 | const mockDeleteResponse = { status: 204 }; 185 | expect(mockDeleteResponse.status).toBe(204); 186 | }); 187 | 188 | test('should return 404 when deleting non-existent user', async () => { 189 | const nonExistentUserId = 999; 190 | 191 | // For actual API testing: 192 | // try { 193 | // await apiHelper.delete(`/users/${nonExistentUserId}`); 194 | // expect(true).toBe(false); // Should not reach here 195 | // } catch (error) { 196 | // expect(error.message).toContain('404'); 197 | // } 198 | 199 | // Mock test 200 | const mockError = { status: 404, message: 'User not found' }; 201 | expect(mockError.status).toBe(404); 202 | }); 203 | }); 204 | 205 | test.describe('Authentication', () => { 206 | test('should require authentication for protected endpoints', async () => { 207 | // Test without auth token 208 | // For actual API testing: 209 | // try { 210 | // await apiHelper.get('/users/profile'); 211 | // expect(true).toBe(false); // Should not reach here 212 | // } catch (error) { 213 | // expect(error.message).toContain('401'); 214 | // } 215 | 216 | // Mock test 217 | const mockAuthError = { status: 401, message: 'Unauthorized' }; 218 | expect(mockAuthError.status).toBe(401); 219 | }); 220 | 221 | test('should allow access with valid token', async () => { 222 | // Set auth token 223 | const mockToken = 'valid-jwt-token'; 224 | apiHelper.setAuthToken(mockToken); 225 | 226 | // For actual API testing: 227 | // const response = await apiHelper.get('/users/profile'); 228 | // expect(response).toBeDefined(); 229 | 230 | // Mock test 231 | const mockProfileResponse = { 232 | id: 1, 233 | name: 'John Doe', 234 | email: 'john@example.com' 235 | }; 236 | 237 | expect(mockProfileResponse.id).toBeDefined(); 238 | expect(mockProfileResponse.name).toBeDefined(); 239 | expect(mockProfileResponse.email).toBeDefined(); 240 | }); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /pages/CheckoutPage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Checkout Page Object Model 3 | */ 4 | class CheckoutPage { 5 | constructor(page) { 6 | this.page = page; 7 | 8 | // Checkout Information Selectors 9 | this.pageTitle = page.locator('.title'); 10 | this.firstNameInput = page.locator('[data-test="firstName"]'); 11 | this.lastNameInput = page.locator('[data-test="lastName"]'); 12 | this.postalCodeInput = page.locator('[data-test="postalCode"]'); 13 | this.continueButton = page.locator('[data-test="continue"]'); 14 | this.cancelButton = page.locator('[data-test="cancel"]'); 15 | this.errorMessage = page.locator('[data-test="error"]'); 16 | 17 | // Checkout Overview Selectors 18 | this.checkoutSummary = page.locator('.checkout_summary_container'); 19 | this.cartItems = page.locator('.cart_item'); 20 | this.paymentInfo = page.locator('.summary_info_label:has-text("Payment Information:")'); 21 | this.shippingInfo = page.locator('.summary_info_label:has-text("Shipping Information:")'); 22 | this.subtotalLabel = page.locator('.summary_subtotal_label'); 23 | this.taxLabel = page.locator('.summary_tax_label'); 24 | this.totalLabel = page.locator('.summary_total_label'); 25 | this.finishButton = page.locator('[data-test="finish"]'); 26 | this.cancelOverviewButton = page.locator('[data-test="cancel"]'); 27 | 28 | // Checkout Complete Selectors 29 | this.successMessage = page.locator('.complete-header'); 30 | this.successText = page.locator('.complete-text'); 31 | this.backHomeButton = page.locator('[data-test="back-to-products"]'); 32 | this.ponyExpressImage = page.locator('.pony_express'); 33 | } 34 | 35 | /** 36 | * Navigate to checkout step one 37 | */ 38 | async gotoStepOne() { 39 | await this.page.goto('/checkout-step-one.html'); 40 | } 41 | 42 | /** 43 | * Navigate to checkout step two (overview) 44 | */ 45 | async gotoStepTwo() { 46 | await this.page.goto('/checkout-step-two.html'); 47 | } 48 | 49 | /** 50 | * Navigate to checkout complete page 51 | */ 52 | async gotoComplete() { 53 | await this.page.goto('/checkout-complete.html'); 54 | } 55 | 56 | /** 57 | * Fill checkout information 58 | * @param {string} firstName - First name 59 | * @param {string} lastName - Last name 60 | * @param {string} postalCode - Postal/ZIP code 61 | */ 62 | async fillCheckoutInformation(firstName, lastName, postalCode) { 63 | await this.firstNameInput.fill(firstName); 64 | await this.lastNameInput.fill(lastName); 65 | await this.postalCodeInput.fill(postalCode); 66 | } 67 | 68 | /** 69 | * Continue to checkout overview 70 | */ 71 | async continueToOverview() { 72 | await this.continueButton.click(); 73 | } 74 | 75 | /** 76 | * Cancel checkout and return to cart 77 | */ 78 | async cancelCheckout() { 79 | await this.cancelButton.click(); 80 | } 81 | 82 | /** 83 | * Finish checkout process 84 | */ 85 | async finishCheckout() { 86 | await this.finishButton.click(); 87 | } 88 | 89 | /** 90 | * Cancel from overview and return to inventory 91 | */ 92 | async cancelFromOverview() { 93 | await this.cancelOverviewButton.click(); 94 | } 95 | 96 | /** 97 | * Get error message text 98 | * @returns {Promise} Error message 99 | */ 100 | async getErrorMessage() { 101 | return await this.errorMessage.textContent(); 102 | } 103 | 104 | /** 105 | * Check if error message is visible 106 | * @returns {Promise} True if error is visible 107 | */ 108 | async isErrorVisible() { 109 | return await this.errorMessage.isVisible(); 110 | } 111 | 112 | /** 113 | * Get subtotal amount 114 | * @returns {Promise} Subtotal amount 115 | */ 116 | async getSubtotal() { 117 | const subtotalText = await this.subtotalLabel.textContent(); 118 | const match = subtotalText.match(/\$(\d+\.\d{2})/); 119 | return match ? parseFloat(match[1]) : 0; 120 | } 121 | 122 | /** 123 | * Get tax amount 124 | * @returns {Promise} Tax amount 125 | */ 126 | async getTax() { 127 | const taxText = await this.taxLabel.textContent(); 128 | const match = taxText.match(/\$(\d+\.\d{2})/); 129 | return match ? parseFloat(match[1]) : 0; 130 | } 131 | 132 | /** 133 | * Get total amount 134 | * @returns {Promise} Total amount 135 | */ 136 | async getTotal() { 137 | const totalText = await this.totalLabel.textContent(); 138 | const match = totalText.match(/\$(\d+\.\d{2})/); 139 | return match ? parseFloat(match[1]) : 0; 140 | } 141 | 142 | /** 143 | * Get payment information 144 | * @returns {Promise} Payment info text 145 | */ 146 | async getPaymentInformation() { 147 | const paymentSection = this.page.locator('.summary_info').first(); 148 | return await paymentSection.textContent(); 149 | } 150 | 151 | /** 152 | * Get shipping information 153 | * @returns {Promise} Shipping info text 154 | */ 155 | async getShippingInformation() { 156 | const shippingSection = this.page.locator('.summary_info').nth(1); 157 | return await shippingSection.textContent(); 158 | } 159 | 160 | /** 161 | * Get all items in checkout overview 162 | * @returns {Promise} Array of cart items 163 | */ 164 | async getCheckoutItems() { 165 | return await this.cartItems.all(); 166 | } 167 | 168 | /** 169 | * Get checkout item count 170 | * @returns {Promise} Number of items 171 | */ 172 | async getCheckoutItemCount() { 173 | return await this.cartItems.count(); 174 | } 175 | 176 | /** 177 | * Verify checkout information form fields 178 | * @returns {Promise} Form field states 179 | */ 180 | async getFormFieldStates() { 181 | return { 182 | firstName: { 183 | value: await this.firstNameInput.inputValue(), 184 | isVisible: await this.firstNameInput.isVisible(), 185 | isEnabled: await this.firstNameInput.isEnabled() 186 | }, 187 | lastName: { 188 | value: await this.lastNameInput.inputValue(), 189 | isVisible: await this.lastNameInput.isVisible(), 190 | isEnabled: await this.lastNameInput.isEnabled() 191 | }, 192 | postalCode: { 193 | value: await this.postalCodeInput.inputValue(), 194 | isVisible: await this.postalCodeInput.isVisible(), 195 | isEnabled: await this.postalCodeInput.isEnabled() 196 | } 197 | }; 198 | } 199 | 200 | /** 201 | * Clear all form fields 202 | */ 203 | async clearAllFields() { 204 | await this.firstNameInput.clear(); 205 | await this.lastNameInput.clear(); 206 | await this.postalCodeInput.clear(); 207 | } 208 | 209 | /** 210 | * Get success message after checkout completion 211 | * @returns {Promise} Success message 212 | */ 213 | async getSuccessMessage() { 214 | return await this.successMessage.textContent(); 215 | } 216 | 217 | /** 218 | * Get success description text 219 | * @returns {Promise} Success description 220 | */ 221 | async getSuccessDescription() { 222 | return await this.successText.textContent(); 223 | } 224 | 225 | /** 226 | * Go back to products page after successful checkout 227 | */ 228 | async backToProducts() { 229 | await this.backHomeButton.click(); 230 | } 231 | 232 | /** 233 | * Check if on checkout step one 234 | * @returns {Promise} True if on step one 235 | */ 236 | async isOnStepOne() { 237 | return this.page.url().includes('/checkout-step-one.html'); 238 | } 239 | 240 | /** 241 | * Check if on checkout step two (overview) 242 | * @returns {Promise} True if on step two 243 | */ 244 | async isOnStepTwo() { 245 | return this.page.url().includes('/checkout-step-two.html'); 246 | } 247 | 248 | /** 249 | * Check if on checkout complete page 250 | * @returns {Promise} True if on complete page 251 | */ 252 | async isOnCompletePage() { 253 | return this.page.url().includes('/checkout-complete.html'); 254 | } 255 | 256 | /** 257 | * Wait for checkout step one to load 258 | */ 259 | async waitForStepOneLoad() { 260 | await this.firstNameInput.waitFor({ state: 'visible' }); 261 | await this.lastNameInput.waitFor({ state: 'visible' }); 262 | await this.postalCodeInput.waitFor({ state: 'visible' }); 263 | } 264 | 265 | /** 266 | * Wait for checkout overview to load 267 | */ 268 | async waitForOverviewLoad() { 269 | await this.checkoutSummary.waitFor({ state: 'visible' }); 270 | await this.subtotalLabel.waitFor({ state: 'visible' }); 271 | await this.totalLabel.waitFor({ state: 'visible' }); 272 | } 273 | 274 | /** 275 | * Wait for checkout complete page to load 276 | */ 277 | async waitForCompleteLoad() { 278 | await this.successMessage.waitFor({ state: 'visible' }); 279 | await this.ponyExpressImage.waitFor({ state: 'visible' }); 280 | } 281 | } 282 | 283 | module.exports = CheckoutPage; 284 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | 4 | parameters { 5 | choice( 6 | name: 'ENVIRONMENT', 7 | choices: ['dev', 'qa', 'staging', 'prod'], 8 | description: 'Environment to run tests against' 9 | ) 10 | choice( 11 | name: 'BROWSER', 12 | choices: ['chromium', 'firefox', 'webkit', 'all'], 13 | description: 'Browser to run tests on' 14 | ) 15 | choice( 16 | name: 'TEST_SUITE', 17 | choices: ['all', 'smoke', 'regression', 'e2e', 'api', 'mobile'], 18 | description: 'Test suite to execute' 19 | ) 20 | booleanParam( 21 | name: 'HEADLESS', 22 | defaultValue: true, 23 | description: 'Run tests in headless mode' 24 | ) 25 | } 26 | 27 | environment { 28 | NODE_VERSION = '18' 29 | PLAYWRIGHT_BROWSERS_PATH = './playwright-browsers' 30 | CI = 'true' 31 | } 32 | 33 | stages { 34 | stage('Checkout') { 35 | steps { 36 | checkout scm 37 | echo "Checked out code for ${env.BRANCH_NAME}" 38 | } 39 | } 40 | 41 | stage('Setup Node.js') { 42 | steps { 43 | script { 44 | def nodeHome = tool name: 'NodeJS-18', type: 'jenkins.plugins.nodejs.tools.NodeJSInstallation' 45 | env.PATH = "${nodeHome}/bin:${env.PATH}" 46 | } 47 | sh 'node --version' 48 | sh 'npm --version' 49 | } 50 | } 51 | 52 | stage('Install Dependencies') { 53 | steps { 54 | sh 'npm ci' 55 | sh 'npx playwright install --with-deps' 56 | } 57 | post { 58 | failure { 59 | echo 'Failed to install dependencies' 60 | } 61 | } 62 | } 63 | 64 | stage('Setup Environment') { 65 | steps { 66 | script { 67 | def baseUrl = '' 68 | def apiBaseUrl = '' 69 | 70 | switch(params.ENVIRONMENT) { 71 | case 'dev': 72 | baseUrl = 'https://dev.saucedemo.com' 73 | apiBaseUrl = 'https://dev-api.saucedemo.com' 74 | break 75 | case 'qa': 76 | baseUrl = 'https://qa.saucedemo.com' 77 | apiBaseUrl = 'https://qa-api.saucedemo.com' 78 | break 79 | case 'staging': 80 | baseUrl = 'https://staging.saucedemo.com' 81 | apiBaseUrl = 'https://staging-api.saucedemo.com' 82 | break 83 | case 'prod': 84 | baseUrl = 'https://www.saucedemo.com' 85 | apiBaseUrl = 'https://api.saucedemo.com' 86 | break 87 | default: 88 | baseUrl = 'https://www.saucedemo.com' 89 | apiBaseUrl = 'https://api.saucedemo.com' 90 | } 91 | 92 | writeFile file: '.env', text: """ 93 | BASE_URL=${baseUrl} 94 | API_BASE_URL=${apiBaseUrl} 95 | TIMEOUT=30000 96 | HEADLESS=${params.HEADLESS} 97 | BROWSER=${params.BROWSER} 98 | TEST_ENV=${params.ENVIRONMENT} 99 | """ 100 | } 101 | } 102 | } 103 | 104 | stage('Run Tests') { 105 | parallel { 106 | stage('E2E Tests') { 107 | when { 108 | anyOf { 109 | expression { params.TEST_SUITE == 'all' } 110 | expression { params.TEST_SUITE == 'e2e' } 111 | expression { params.TEST_SUITE == 'regression' } 112 | } 113 | } 114 | steps { 115 | script { 116 | def browserArg = params.BROWSER == 'all' ? '' : "--project=${params.BROWSER}" 117 | sh "npx playwright test tests/e2e/ ${browserArg}" 118 | } 119 | } 120 | post { 121 | always { 122 | publishHTML([ 123 | allowMissing: false, 124 | alwaysLinkToLastBuild: true, 125 | keepAll: true, 126 | reportDir: 'reports/html-report', 127 | reportFiles: 'index.html', 128 | reportName: 'E2E Test Report', 129 | reportTitles: 'E2E Tests' 130 | ]) 131 | } 132 | } 133 | } 134 | 135 | stage('API Tests') { 136 | when { 137 | anyOf { 138 | expression { params.TEST_SUITE == 'all' } 139 | expression { params.TEST_SUITE == 'api' } 140 | expression { params.TEST_SUITE == 'regression' } 141 | } 142 | } 143 | steps { 144 | sh 'npx playwright test tests/api/ --project=chromium' 145 | } 146 | post { 147 | always { 148 | publishHTML([ 149 | allowMissing: false, 150 | alwaysLinkToLastBuild: true, 151 | keepAll: true, 152 | reportDir: 'reports/html-report', 153 | reportFiles: 'index.html', 154 | reportName: 'API Test Report', 155 | reportTitles: 'API Tests' 156 | ]) 157 | } 158 | } 159 | } 160 | 161 | stage('Mobile Tests') { 162 | when { 163 | anyOf { 164 | expression { params.TEST_SUITE == 'all' } 165 | expression { params.TEST_SUITE == 'mobile' } 166 | expression { params.TEST_SUITE == 'regression' } 167 | } 168 | } 169 | steps { 170 | sh 'npx playwright test tests/mobile/ --project=chromium' 171 | } 172 | post { 173 | always { 174 | publishHTML([ 175 | allowMissing: false, 176 | alwaysLinkToLastBuild: true, 177 | keepAll: true, 178 | reportDir: 'reports/html-report', 179 | reportFiles: 'index.html', 180 | reportName: 'Mobile Test Report', 181 | reportTitles: 'Mobile Tests' 182 | ]) 183 | } 184 | } 185 | } 186 | 187 | stage('Smoke Tests') { 188 | when { 189 | anyOf { 190 | expression { params.TEST_SUITE == 'smoke' } 191 | expression { params.TEST_SUITE == 'all' } 192 | } 193 | } 194 | steps { 195 | sh 'npx playwright test --grep "@smoke"' 196 | } 197 | } 198 | } 199 | } 200 | 201 | stage('Collect Results') { 202 | steps { 203 | script { 204 | // Collect test results 205 | if (fileExists('reports/test-results.json')) { 206 | def testResults = readJSON file: 'reports/test-results.json' 207 | echo "Test Results: ${testResults}" 208 | } 209 | } 210 | 211 | // Archive artifacts 212 | archiveArtifacts artifacts: 'reports/**/*', fingerprint: true 213 | archiveArtifacts artifacts: 'screenshots/**/*', fingerprint: true, allowEmptyArchive: true 214 | 215 | // Publish test results 216 | publishTestResults testResultsPattern: 'reports/junit-results.xml' 217 | } 218 | } 219 | } 220 | 221 | post { 222 | always { 223 | cleanWs() 224 | } 225 | success { 226 | script { 227 | if (env.BRANCH_NAME == 'main' || env.BRANCH_NAME == 'master') { 228 | slackSend( 229 | channel: '#test-results', 230 | color: 'good', 231 | message: "✅ Playwright tests passed for ${params.ENVIRONMENT} environment\\n" + 232 | "Branch: ${env.BRANCH_NAME}\\n" + 233 | "Build: ${env.BUILD_URL}" 234 | ) 235 | } 236 | } 237 | } 238 | failure { 239 | slackSend( 240 | channel: '#test-failures', 241 | color: 'danger', 242 | message: "❌ Playwright tests failed for ${params.ENVIRONMENT} environment\\n" + 243 | "Branch: ${env.BRANCH_NAME}\\n" + 244 | "Build: ${env.BUILD_URL}\\n" + 245 | "Please check the logs for details." 246 | ) 247 | 248 | emailext( 249 | subject: "Test Failure: Playwright Tests - ${env.JOB_NAME} #${env.BUILD_NUMBER}", 250 | body: """ 251 |

Test Execution Failed

252 |

Environment: ${params.ENVIRONMENT}

253 |

Browser: ${params.BROWSER}

254 |

Test Suite: ${params.TEST_SUITE}

255 |

Build URL: ${env.BUILD_URL}

256 |

Please check the build logs and test reports for more details.

257 | """, 258 | to: "${env.CHANGE_AUTHOR_EMAIL}, qa-team@company.com", 259 | mimeType: 'text/html' 260 | ) 261 | } 262 | unstable { 263 | slackSend( 264 | channel: '#test-results', 265 | color: 'warning', 266 | message: "⚠️ Playwright tests completed with some failures for ${params.ENVIRONMENT} environment\\n" + 267 | "Branch: ${env.BRANCH_NAME}\\n" + 268 | "Build: ${env.BUILD_URL}" 269 | ) 270 | } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /tests/tests-examples/demo-todo-app.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, type Page } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('https://demo.playwright.dev/todomvc'); 5 | }); 6 | 7 | const TODO_ITEMS = [ 8 | 'buy some cheese', 9 | 'feed the cat', 10 | 'book a doctors appointment' 11 | ] as const; 12 | 13 | test.describe('New Todo', () => { 14 | test('should allow me to add todo items', async ({ page }) => { 15 | // create a new todo locator 16 | const newTodo = page.getByPlaceholder('What needs to be done?'); 17 | 18 | // Create 1st todo. 19 | await newTodo.fill(TODO_ITEMS[0]); 20 | await newTodo.press('Enter'); 21 | 22 | // Make sure the list only has one todo item. 23 | await expect(page.getByTestId('todo-title')).toHaveText([ 24 | TODO_ITEMS[0] 25 | ]); 26 | 27 | // Create 2nd todo. 28 | await newTodo.fill(TODO_ITEMS[1]); 29 | await newTodo.press('Enter'); 30 | 31 | // Make sure the list now has two todo items. 32 | await expect(page.getByTestId('todo-title')).toHaveText([ 33 | TODO_ITEMS[0], 34 | TODO_ITEMS[1] 35 | ]); 36 | 37 | await checkNumberOfTodosInLocalStorage(page, 2); 38 | }); 39 | 40 | test('should clear text input field when an item is added', async ({ page }) => { 41 | // create a new todo locator 42 | const newTodo = page.getByPlaceholder('What needs to be done?'); 43 | 44 | // Create one todo item. 45 | await newTodo.fill(TODO_ITEMS[0]); 46 | await newTodo.press('Enter'); 47 | 48 | // Check that input is empty. 49 | await expect(newTodo).toBeEmpty(); 50 | await checkNumberOfTodosInLocalStorage(page, 1); 51 | }); 52 | 53 | test('should append new items to the bottom of the list', async ({ page }) => { 54 | // Create 3 items. 55 | await createDefaultTodos(page); 56 | 57 | // create a todo count locator 58 | const todoCount = page.getByTestId('todo-count') 59 | 60 | // Check test using different methods. 61 | await expect(page.getByText('3 items left')).toBeVisible(); 62 | await expect(todoCount).toHaveText('3 items left'); 63 | await expect(todoCount).toContainText('3'); 64 | await expect(todoCount).toHaveText(/3/); 65 | 66 | // Check all items in one call. 67 | await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); 68 | await checkNumberOfTodosInLocalStorage(page, 3); 69 | }); 70 | }); 71 | 72 | test.describe('Mark all as completed', () => { 73 | test.beforeEach(async ({ page }) => { 74 | await createDefaultTodos(page); 75 | await checkNumberOfTodosInLocalStorage(page, 3); 76 | }); 77 | 78 | test.afterEach(async ({ page }) => { 79 | await checkNumberOfTodosInLocalStorage(page, 3); 80 | }); 81 | 82 | test('should allow me to mark all items as completed', async ({ page }) => { 83 | // Complete all todos. 84 | await page.getByLabel('Mark all as complete').check(); 85 | 86 | // Ensure all todos have 'completed' class. 87 | await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); 88 | await checkNumberOfCompletedTodosInLocalStorage(page, 3); 89 | }); 90 | 91 | test('should allow me to clear the complete state of all items', async ({ page }) => { 92 | const toggleAll = page.getByLabel('Mark all as complete'); 93 | // Check and then immediately uncheck. 94 | await toggleAll.check(); 95 | await toggleAll.uncheck(); 96 | 97 | // Should be no completed classes. 98 | await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); 99 | }); 100 | 101 | test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { 102 | const toggleAll = page.getByLabel('Mark all as complete'); 103 | await toggleAll.check(); 104 | await expect(toggleAll).toBeChecked(); 105 | await checkNumberOfCompletedTodosInLocalStorage(page, 3); 106 | 107 | // Uncheck first todo. 108 | const firstTodo = page.getByTestId('todo-item').nth(0); 109 | await firstTodo.getByRole('checkbox').uncheck(); 110 | 111 | // Reuse toggleAll locator and make sure its not checked. 112 | await expect(toggleAll).not.toBeChecked(); 113 | 114 | await firstTodo.getByRole('checkbox').check(); 115 | await checkNumberOfCompletedTodosInLocalStorage(page, 3); 116 | 117 | // Assert the toggle all is checked again. 118 | await expect(toggleAll).toBeChecked(); 119 | }); 120 | }); 121 | 122 | test.describe('Item', () => { 123 | 124 | test('should allow me to mark items as complete', async ({ page }) => { 125 | // create a new todo locator 126 | const newTodo = page.getByPlaceholder('What needs to be done?'); 127 | 128 | // Create two items. 129 | for (const item of TODO_ITEMS.slice(0, 2)) { 130 | await newTodo.fill(item); 131 | await newTodo.press('Enter'); 132 | } 133 | 134 | // Check first item. 135 | const firstTodo = page.getByTestId('todo-item').nth(0); 136 | await firstTodo.getByRole('checkbox').check(); 137 | await expect(firstTodo).toHaveClass('completed'); 138 | 139 | // Check second item. 140 | const secondTodo = page.getByTestId('todo-item').nth(1); 141 | await expect(secondTodo).not.toHaveClass('completed'); 142 | await secondTodo.getByRole('checkbox').check(); 143 | 144 | // Assert completed class. 145 | await expect(firstTodo).toHaveClass('completed'); 146 | await expect(secondTodo).toHaveClass('completed'); 147 | }); 148 | 149 | test('should allow me to un-mark items as complete', async ({ page }) => { 150 | // create a new todo locator 151 | const newTodo = page.getByPlaceholder('What needs to be done?'); 152 | 153 | // Create two items. 154 | for (const item of TODO_ITEMS.slice(0, 2)) { 155 | await newTodo.fill(item); 156 | await newTodo.press('Enter'); 157 | } 158 | 159 | const firstTodo = page.getByTestId('todo-item').nth(0); 160 | const secondTodo = page.getByTestId('todo-item').nth(1); 161 | const firstTodoCheckbox = firstTodo.getByRole('checkbox'); 162 | 163 | await firstTodoCheckbox.check(); 164 | await expect(firstTodo).toHaveClass('completed'); 165 | await expect(secondTodo).not.toHaveClass('completed'); 166 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 167 | 168 | await firstTodoCheckbox.uncheck(); 169 | await expect(firstTodo).not.toHaveClass('completed'); 170 | await expect(secondTodo).not.toHaveClass('completed'); 171 | await checkNumberOfCompletedTodosInLocalStorage(page, 0); 172 | }); 173 | 174 | test('should allow me to edit an item', async ({ page }) => { 175 | await createDefaultTodos(page); 176 | 177 | const todoItems = page.getByTestId('todo-item'); 178 | const secondTodo = todoItems.nth(1); 179 | await secondTodo.dblclick(); 180 | await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); 181 | await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); 182 | await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); 183 | 184 | // Explicitly assert the new text value. 185 | await expect(todoItems).toHaveText([ 186 | TODO_ITEMS[0], 187 | 'buy some sausages', 188 | TODO_ITEMS[2] 189 | ]); 190 | await checkTodosInLocalStorage(page, 'buy some sausages'); 191 | }); 192 | }); 193 | 194 | test.describe('Editing', () => { 195 | test.beforeEach(async ({ page }) => { 196 | await createDefaultTodos(page); 197 | await checkNumberOfTodosInLocalStorage(page, 3); 198 | }); 199 | 200 | test('should hide other controls when editing', async ({ page }) => { 201 | const todoItem = page.getByTestId('todo-item').nth(1); 202 | await todoItem.dblclick(); 203 | await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); 204 | await expect(todoItem.locator('label', { 205 | hasText: TODO_ITEMS[1], 206 | })).not.toBeVisible(); 207 | await checkNumberOfTodosInLocalStorage(page, 3); 208 | }); 209 | 210 | test('should save edits on blur', async ({ page }) => { 211 | const todoItems = page.getByTestId('todo-item'); 212 | await todoItems.nth(1).dblclick(); 213 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); 214 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); 215 | 216 | await expect(todoItems).toHaveText([ 217 | TODO_ITEMS[0], 218 | 'buy some sausages', 219 | TODO_ITEMS[2], 220 | ]); 221 | await checkTodosInLocalStorage(page, 'buy some sausages'); 222 | }); 223 | 224 | test('should trim entered text', async ({ page }) => { 225 | const todoItems = page.getByTestId('todo-item'); 226 | await todoItems.nth(1).dblclick(); 227 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); 228 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); 229 | 230 | await expect(todoItems).toHaveText([ 231 | TODO_ITEMS[0], 232 | 'buy some sausages', 233 | TODO_ITEMS[2], 234 | ]); 235 | await checkTodosInLocalStorage(page, 'buy some sausages'); 236 | }); 237 | 238 | test('should remove the item if an empty text string was entered', async ({ page }) => { 239 | const todoItems = page.getByTestId('todo-item'); 240 | await todoItems.nth(1).dblclick(); 241 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); 242 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); 243 | 244 | await expect(todoItems).toHaveText([ 245 | TODO_ITEMS[0], 246 | TODO_ITEMS[2], 247 | ]); 248 | }); 249 | 250 | test('should cancel edits on escape', async ({ page }) => { 251 | const todoItems = page.getByTestId('todo-item'); 252 | await todoItems.nth(1).dblclick(); 253 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); 254 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); 255 | await expect(todoItems).toHaveText(TODO_ITEMS); 256 | }); 257 | }); 258 | 259 | test.describe('Counter', () => { 260 | test('should display the current number of todo items', async ({ page }) => { 261 | // create a new todo locator 262 | const newTodo = page.getByPlaceholder('What needs to be done?'); 263 | 264 | // create a todo count locator 265 | const todoCount = page.getByTestId('todo-count') 266 | 267 | await newTodo.fill(TODO_ITEMS[0]); 268 | await newTodo.press('Enter'); 269 | 270 | await expect(todoCount).toContainText('1'); 271 | 272 | await newTodo.fill(TODO_ITEMS[1]); 273 | await newTodo.press('Enter'); 274 | await expect(todoCount).toContainText('2'); 275 | 276 | await checkNumberOfTodosInLocalStorage(page, 2); 277 | }); 278 | }); 279 | 280 | test.describe('Clear completed button', () => { 281 | test.beforeEach(async ({ page }) => { 282 | await createDefaultTodos(page); 283 | }); 284 | 285 | test('should display the correct text', async ({ page }) => { 286 | await page.locator('.todo-list li .toggle').first().check(); 287 | await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); 288 | }); 289 | 290 | test('should remove completed items when clicked', async ({ page }) => { 291 | const todoItems = page.getByTestId('todo-item'); 292 | await todoItems.nth(1).getByRole('checkbox').check(); 293 | await page.getByRole('button', { name: 'Clear completed' }).click(); 294 | await expect(todoItems).toHaveCount(2); 295 | await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); 296 | }); 297 | 298 | test('should be hidden when there are no items that are completed', async ({ page }) => { 299 | await page.locator('.todo-list li .toggle').first().check(); 300 | await page.getByRole('button', { name: 'Clear completed' }).click(); 301 | await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); 302 | }); 303 | }); 304 | 305 | test.describe('Persistence', () => { 306 | test('should persist its data', async ({ page }) => { 307 | // create a new todo locator 308 | const newTodo = page.getByPlaceholder('What needs to be done?'); 309 | 310 | for (const item of TODO_ITEMS.slice(0, 2)) { 311 | await newTodo.fill(item); 312 | await newTodo.press('Enter'); 313 | } 314 | 315 | const todoItems = page.getByTestId('todo-item'); 316 | const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); 317 | await firstTodoCheck.check(); 318 | await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); 319 | await expect(firstTodoCheck).toBeChecked(); 320 | await expect(todoItems).toHaveClass(['completed', '']); 321 | 322 | // Ensure there is 1 completed item. 323 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 324 | 325 | // Now reload. 326 | await page.reload(); 327 | await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); 328 | await expect(firstTodoCheck).toBeChecked(); 329 | await expect(todoItems).toHaveClass(['completed', '']); 330 | }); 331 | }); 332 | 333 | test.describe('Routing', () => { 334 | test.beforeEach(async ({ page }) => { 335 | await createDefaultTodos(page); 336 | // make sure the app had a chance to save updated todos in storage 337 | // before navigating to a new view, otherwise the items can get lost :( 338 | // in some frameworks like Durandal 339 | await checkTodosInLocalStorage(page, TODO_ITEMS[0]); 340 | }); 341 | 342 | test('should allow me to display active items', async ({ page }) => { 343 | const todoItem = page.getByTestId('todo-item'); 344 | await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); 345 | 346 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 347 | await page.getByRole('link', { name: 'Active' }).click(); 348 | await expect(todoItem).toHaveCount(2); 349 | await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); 350 | }); 351 | 352 | test('should respect the back button', async ({ page }) => { 353 | const todoItem = page.getByTestId('todo-item'); 354 | await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); 355 | 356 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 357 | 358 | await test.step('Showing all items', async () => { 359 | await page.getByRole('link', { name: 'All' }).click(); 360 | await expect(todoItem).toHaveCount(3); 361 | }); 362 | 363 | await test.step('Showing active items', async () => { 364 | await page.getByRole('link', { name: 'Active' }).click(); 365 | }); 366 | 367 | await test.step('Showing completed items', async () => { 368 | await page.getByRole('link', { name: 'Completed' }).click(); 369 | }); 370 | 371 | await expect(todoItem).toHaveCount(1); 372 | await page.goBack(); 373 | await expect(todoItem).toHaveCount(2); 374 | await page.goBack(); 375 | await expect(todoItem).toHaveCount(3); 376 | }); 377 | 378 | test('should allow me to display completed items', async ({ page }) => { 379 | await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); 380 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 381 | await page.getByRole('link', { name: 'Completed' }).click(); 382 | await expect(page.getByTestId('todo-item')).toHaveCount(1); 383 | }); 384 | 385 | test('should allow me to display all items', async ({ page }) => { 386 | await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); 387 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 388 | await page.getByRole('link', { name: 'Active' }).click(); 389 | await page.getByRole('link', { name: 'Completed' }).click(); 390 | await page.getByRole('link', { name: 'All' }).click(); 391 | await expect(page.getByTestId('todo-item')).toHaveCount(3); 392 | }); 393 | 394 | test('should highlight the currently applied filter', async ({ page }) => { 395 | await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); 396 | 397 | //create locators for active and completed links 398 | const activeLink = page.getByRole('link', { name: 'Active' }); 399 | const completedLink = page.getByRole('link', { name: 'Completed' }); 400 | await activeLink.click(); 401 | 402 | // Page change - active items. 403 | await expect(activeLink).toHaveClass('selected'); 404 | await completedLink.click(); 405 | 406 | // Page change - completed items. 407 | await expect(completedLink).toHaveClass('selected'); 408 | }); 409 | }); 410 | 411 | async function createDefaultTodos(page: Page) { 412 | // create a new todo locator 413 | const newTodo = page.getByPlaceholder('What needs to be done?'); 414 | 415 | for (const item of TODO_ITEMS) { 416 | await newTodo.fill(item); 417 | await newTodo.press('Enter'); 418 | } 419 | } 420 | 421 | async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { 422 | return await page.waitForFunction(e => { 423 | return JSON.parse(localStorage['react-todos']).length === e; 424 | }, expected); 425 | } 426 | 427 | async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { 428 | return await page.waitForFunction(e => { 429 | return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; 430 | }, expected); 431 | } 432 | 433 | async function checkTodosInLocalStorage(page: Page, title: string) { 434 | return await page.waitForFunction(t => { 435 | return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); 436 | }, title); 437 | } 438 | --------------------------------------------------------------------------------