diff --git a/.env b/.env
new file mode 100644
index 0000000..4b58617
--- /dev/null
+++ b/.env
@@ -0,0 +1,43 @@
+# Environment Configuration
+TEST_ENV=dev
+NODE_ENV=development
+
+# Application URLs
+BASE_URL=https://www.saucedemo.com/
+STAGING_URL=https://staging.saucedemo.com/
+PROD_URL=https://www.saucedemo.com/
+
+# Browser Configuration
+BROWSER=chromium
+HEADLESS=false
+SLOW_MO=0
+TIMEOUT=30000
+RETRIES=2
+
+# Viewport Settings
+VIEWPORT_WIDTH=1920
+VIEWPORT_HEIGHT=1080
+
+# Parallel Execution
+PARALLEL_WORKERS=1
+
+# Video Recording
+VIDEO_RECORDING=true
+VIDEO_MODE=retain-on-failure
+
+# Screenshots
+SCREENSHOT_ON_FAILURE=true
+
+# Tracing
+TRACE_ENABLED=true
+TRACE_MODE=retain-on-failure
+
+# Logging
+LOG_LEVEL=info
+
+# Test Data (if needed)
+# DEFAULT_USERNAME=standard_user
+# DEFAULT_PASSWORD=secret_sauce
+
+# CI/CD Specific (optional)
+CI=false
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4b58617
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,43 @@
+# Environment Configuration
+TEST_ENV=dev
+NODE_ENV=development
+
+# Application URLs
+BASE_URL=https://www.saucedemo.com/
+STAGING_URL=https://staging.saucedemo.com/
+PROD_URL=https://www.saucedemo.com/
+
+# Browser Configuration
+BROWSER=chromium
+HEADLESS=false
+SLOW_MO=0
+TIMEOUT=30000
+RETRIES=2
+
+# Viewport Settings
+VIEWPORT_WIDTH=1920
+VIEWPORT_HEIGHT=1080
+
+# Parallel Execution
+PARALLEL_WORKERS=1
+
+# Video Recording
+VIDEO_RECORDING=true
+VIDEO_MODE=retain-on-failure
+
+# Screenshots
+SCREENSHOT_ON_FAILURE=true
+
+# Tracing
+TRACE_ENABLED=true
+TRACE_MODE=retain-on-failure
+
+# Logging
+LOG_LEVEL=info
+
+# Test Data (if needed)
+# DEFAULT_USERNAME=standard_user
+# DEFAULT_PASSWORD=secret_sauce
+
+# CI/CD Specific (optional)
+CI=false
\ No newline at end of file
diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml
new file mode 100644
index 0000000..45a05b7
--- /dev/null
+++ b/.github/workflows/playwright-tests.yml
@@ -0,0 +1,127 @@
+name: Playwright Cucumber Tests
+
+on:
+ push:
+ branches: [ main, develop ]
+ pull_request:
+ branches: [ main, develop ]
+ workflow_dispatch:
+ inputs:
+ environment:
+ description: 'Environment to run tests against'
+ required: true
+ default: 'staging'
+ type: choice
+ options:
+ - dev
+ - staging
+ - prod
+ browser:
+ description: 'Browser to run tests on'
+ required: true
+ default: 'chromium'
+ type: choice
+ options:
+ - chromium
+ - firefox
+ - webkit
+ - all
+ parallel:
+ description: 'Number of parallel workers'
+ required: false
+ default: '2'
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ browser: ${{ github.event.inputs.browser == 'all' && fromJSON('["chromium", "firefox", "webkit"]') || fromJSON(format('["{0}"]', github.event.inputs.browser || 'chromium')) }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps ${{ matrix.browser }}
+
+ - name: Create .env file
+ run: |
+ echo "TEST_ENV=${{ github.event.inputs.environment || 'ci' }}" >> .env
+ echo "BROWSER=${{ matrix.browser }}" >> .env
+ echo "HEADLESS=true" >> .env
+ echo "PARALLEL_WORKERS=${{ github.event.inputs.parallel || '2' }}" >> .env
+ echo "VIDEO_RECORDING=true" >> .env
+ echo "VIDEO_MODE=on-first-retry" >> .env
+ echo "SCREENSHOT_ON_FAILURE=true" >> .env
+ echo "TRACE_ENABLED=true" >> .env
+ echo "TRACE_MODE=retain-on-failure" >> .env
+ echo "CI=true" >> .env
+
+ - name: Run tests
+ run: npm run test:ci
+ env:
+ BROWSER: ${{ matrix.browser }}
+ TEST_ENV: ${{ github.event.inputs.environment || 'ci' }}
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-${{ matrix.browser }}
+ path: |
+ test-results/
+ logs/
+ retention-days: 30
+
+ - name: Upload Playwright traces
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: traces-${{ matrix.browser }}
+ path: test-results/traces/
+ retention-days: 7
+
+ - name: Upload screenshots
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: screenshots-${{ matrix.browser }}
+ path: test-results/screenshots/
+ retention-days: 7
+
+ - name: Upload videos
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: videos-${{ matrix.browser }}
+ path: test-results/videos/
+ retention-days: 7
+
+ - name: Publish test report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: cucumber-report-${{ matrix.browser }}
+ path: test-results/reports/
+ retention-days: 30
+
+ notify:
+ needs: test
+ runs-on: ubuntu-latest
+ if: always()
+ steps:
+ - name: Send notification
+ run: |
+ echo "Tests completed with status: ${{ needs.test.result }}"
+ # Add your notification logic here (Slack, Teams, Email, etc.)
\ No newline at end of file
diff --git a/README.md b/README.md
index ccfe588..41a0804 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,948 @@
-# Playwright + Cucumber Test Framework
+# š Playwright + Cucumber BDD Test Automation Framework
-Quick notes and how-to.
+
-## Prerequisites
-- Node.js (recommended LTS)
-- npm install run before using scripts: npm ci or npm install
+
+
+
+
-## Install
-- npm ci
+**A modern, comprehensive, production-ready test automation framework with Visual Regression, Performance Testing, Accessibility Testing, and Mobile Emulation**
-## Run tests (single-worker)
-- node tests/run-cucumber.js
-- or: npx cucumber-js --require-module ts-node/register tests/features/**/*.feature
+[Features](#-features) ⢠[Quick Start](#-quick-start) ⢠[Commands](#-test-commands) ⢠[Documentation](#-documentation) ⢠[Examples](#-examples)
-## Run tests (parallel)
-- node tests/run-cucumber.js --parallel=2
-- OR set env: PARALLEL=2 or PARALLEL_WORKERS=2
-- run-cucumber script will auto-limit workers to CPU and disable heavy artifacts
+[]()
+[]()
+[]()
+[]()
-## Important environment variables
-- HEADLESS (true|false) ā browser headless mode
-- RECORD_VIDEO (true|false) ā video recording (disabled automatically for parallel runs)
-- PARALLEL or PARALLEL_WORKERS ā number of workers for cucumber-js
-- BROWSER_WS_ENDPOINT ā used when a shared browser server is launched for parallel runs
+
-## Performance tips
-- Video recording per-worker is expensive. Keep RECORD_VIDEO off for parallel runs.
-- Choose parallel count based on CPU cores (2ā4 recommended on small machines).
-- To avoid overhead at start/end, run-cucumber can launch a single BrowserServer and workers will connect to it (see tests/run-cucumber.backup.js).
-- If scenarios are very short, parallel overhead may outweigh benefits ā run with 1 or 2 workers.
+---
-## Troubleshooting
-- If AfterAll hooks time out in parallel runs, increase setDefaultTimeout or specify hook timeout in tests/support/hooks.ts.
-- Ensure reporter output files are unique per worker; run-cucumber sets unique names for parallel runs.
-- To inspect the actual command run, the runner logs the computed cucumber-js command.
+## š Overview
-## Do not commit
-- Test artifacts (videos, screenshots, test-results) are gitignored by default.
+This framework provides a **complete testing solution** for modern web applications, combining the power of Playwright with Cucumber BDD, enhanced with enterprise-grade features including Visual Regression, Performance Monitoring, Accessibility Compliance, and Mobile Device Testing.
-If you want, I can:
-- Add a short npm script set in package.json for running with common flags.
-- Add a sample .env.example with recommended env settings.
\ No newline at end of file
+### Why This Framework?
+
+- ā
**All-in-One Solution**: UI, API, Visual, Performance, Accessibility, Mobile - everything included
+- ā
**Production-Ready**: Battle-tested with comprehensive error handling and diagnostics
+- ā
**Developer-Friendly**: TypeScript with excellent IDE support and type safety
+- ā
**Business-Readable**: Cucumber's Gherkin syntax for stakeholder collaboration
+- ā
**Fast & Reliable**: Parallel execution, auto-waiting, and smart retry mechanisms
+- ā
**Quality-First**: Built-in performance budgets, accessibility checks, and visual comparisons
+
+---
+
+## š Features
+
+### šÆ Core Testing Capabilities
+
+#### UI Test Automation
+- š **Playwright Integration** - Modern, reliable cross-browser automation
+- š„ **Cucumber BDD** - Write tests in plain English using Gherkin syntax
+- š **TypeScript** - Full type safety and excellent developer experience
+- šļø **Page Object Model** - Maintainable, scalable test architecture
+- š **World Pattern** - Proper test isolation and state management
+
+#### API Testing (Phase 2) š
+- š **Full REST API Support** - GET, POST, PUT, PATCH, DELETE
+- š **Authentication Management** - Token-based auth handling
+- ā
**Response Assertions** - Built-in status and content validation
+- š **Request/Response Logging** - Automatic logging with performance metrics
+- š **Hybrid Testing** - Seamlessly combine API and UI tests
+
+#### Network Control (Phase 2) š
+- šøļø **Traffic Capture** - Monitor all network requests/responses
+- š **API Mocking** - Mock external dependencies for deterministic tests
+- š« **Resource Blocking** - Block ads, analytics, fonts for faster execution
+- š **Network Simulation** - Test slow connections and offline scenarios
+- š **Performance Metrics** - Track request durations and patterns
+
+#### Visual Regression Testing (Phase 3) š
+- šø **Screenshot Comparison** - Pixel-perfect visual regression testing
+- šØ **Element Comparison** - Test specific components independently
+- š **Dynamic Content Masking** - Hide changing elements (dates, counters)
+- š± **Responsive Testing** - Compare across multiple viewports
+- šÆ **Hover/Focus States** - Test interactive element states
+- š **Custom Thresholds** - Configurable tolerance for differences
+
+#### Performance Testing (Phase 3) š
+- ā” **Web Vitals** - FCP, LCP, CLS, TTI, TBT measurements
+- š **Performance Budgets** - Set and enforce performance thresholds
+- šÆ **Performance Scoring** - 0-100 score based on metrics
+- š **Resource Analysis** - Identify slowest/largest resources
+- ā±ļø **TTFB Measurement** - Time to First Byte tracking
+- š **Automated Reports** - Detailed performance reports with violations
+
+#### Accessibility Testing (Phase 3) š
+- āæ **WCAG Compliance** - WCAG 2.0, 2.1, 2.2 Level A, AA, AAA
+- š **axe-core Integration** - Industry-standard accessibility testing
+- šØ **Color Contrast** - Automatic contrast ratio checking
+- š **Form Validation** - Label and input accessibility checks
+- š¼ļø **Image Alt Text** - Verify all images have descriptions
+- āØļø **Keyboard Navigation** - Test keyboard accessibility
+- š **Accessibility Scoring** - Get compliance scores (0-100)
+
+#### Mobile Device Emulation (Phase 3) š
+- š± **20+ Devices** - iPhone, iPad, Android phones, tablets
+- š **Touch Gestures** - Tap, swipe, pinch, long press
+- š **Device Rotation** - Portrait/landscape testing
+- š **Geolocation** - Test location-based features
+- š” **Network Conditions** - Emulate 3G, 4G, WiFi, offline
+- š **Responsive Breakpoints** - Test all common screen sizes
+
+### āļø Test Execution
+
+- ā” **Parallel Execution** - Run tests concurrently (2-8+ workers)
+- š **Multi-Browser Support** - Chromium, Firefox, WebKit
+- šÆ **Multi-Environment** - Dev, Staging, Production, CI configurations
+- š·ļø **Tag-Based Filtering** - Run specific suites (@smoke, @regression, @api, @visual, etc.)
+- š **Smart Retry Logic** - Exponential backoff for flaky tests
+- š¬ **Debug Mode** - Headed browser with slow motion for troubleshooting
+
+### š Debugging & Diagnostics
+
+- šø **Screenshots on Failure** - Automatic capture with timestamping
+- š„ **Video Recording** - Configurable modes (on-failure, always, first-retry)
+- š **Playwright Traces** - Deep debugging with timeline and network logs
+- š **Advanced Logging** - Winston-based structured logging with levels
+- šØ **Colored Console Output** - Easy-to-read test execution logs with emojis
+- š§ **Error Diagnostics** - Context capture, stack traces, page state
+- šøļø **Network Logs** - Request/response capture in traces
+- š» **Browser Console** - Capture and attach console logs
+
+### š Reporting & CI/CD
+
+#### Reports
+- š **Standard Cucumber HTML** - Feature/scenario breakdown
+- ⨠**Enhanced HTML Summary** - Beautiful reports with charts and statistics
+- š **JSON Reports** - Machine-readable for integrations
+- š **JUnit XML** - For CI/CD integration
+- šÆ **Performance Reports** - Detailed Web Vitals and budget violations
+- āæ **Accessibility Reports** - WCAG violations with remediation guidance
+- šø **Visual Diff Reports** - Side-by-side comparisons with highlights
+
+#### CI/CD Integration
+- š **GitHub Actions Ready** - Pre-configured workflow included
+- š³ **Docker Support** - Containerized execution (coming soon)
+- š§ **Environment Variables** - Easy configuration management
+- š¦ **Artifact Upload** - Screenshots, videos, traces, reports
+- šÆ **Quality Gates** - Fail builds on budget violations or accessibility issues
+
+### š ļø Developer Experience
+
+- šļø **Centralized Configuration** - Single source for all settings
+- š **Retry Logic** - Smart exponential backoff
+- š”ļø **Error Handling** - Comprehensive error utilities
+- š **Code Quality** - ESLint and Prettier configurations
+- š¦ **Type Safety** - Full TypeScript throughout
+- š **Comprehensive Docs** - Detailed guides for all features
+- š **Example Tests** - Real-world examples for every feature
+
+### š¦ Test Data Management (Phase 2)
+
+- šļø **Static Data Repository** - Predefined users, products, checkout info
+- š² **Dynamic Generation** - Random usernames, emails, passwords
+- š **File-Based Loading** - JSON data files with caching
+- š **Environment-Specific** - Different data per environment
+- šļø **Data Builders** - Fluent API for test data creation
+
+---
+
+## š Prerequisites
+
+- **Node.js** >= 18.x
+- **npm** >= 9.x
+- Basic understanding of TypeScript and Cucumber BDD
+
+---
+
+## š Quick Start
+
+### Installation
+
+```bash
+# Clone the repository
+git clone https://github.com/yourusername/playwright-cucumber-framework.git
+cd playwright-cucumber-framework
+
+# Install dependencies
+npm install
+
+# Install Playwright browsers
+npx playwright install
+
+# Copy environment configuration
+cp .env.example .env
+
+# Edit .env with your settings
+nano .env
+```
+
+### Run Your First Test
+
+```bash
+# Run all tests
+npm test
+
+# Run smoke tests only
+npm run test:smoke
+
+# Run in debug mode (visible browser)
+npm run test:debug
+
+# View report
+npm run report
+```
+
+---
+
+## šÆ Test Commands
+
+### Basic Execution
+```bash
+npm test # Run all tests
+npm run test:smoke # Quick critical path tests
+npm run test:regression # Full regression suite
+npm run test:parallel # Parallel execution (2 workers)
+npm run test:parallel:4 # Parallel with 4 workers
+npm run test:debug # Debug mode (headed + slow motion)
+```
+
+### Test Types
+```bash
+npm run test:ui # UI tests only
+npm run test:api # API tests only
+npm run test:visual # Visual regression tests
+npm run test:performance # Performance tests
+npm run test:accessibility # Accessibility tests
+npm run test:a11y # Alias for accessibility
+npm run test:mobile # Mobile device tests
+npm run test:responsive # Responsive design tests
+npm run test:network # Network monitoring tests
+npm run test:mock # Tests with API mocking
+npm run test:advanced # All advanced tests
+npm run test:quality # Complete quality assessment
+```
+
+### Environments
+```bash
+npm run test:dev # Development environment
+npm run test:staging # Staging environment
+npm run test:prod # Production environment
+npm run test:ci # CI-optimized (with compilation)
+```
+
+### Browsers
+```bash
+npm run test:chromium # Google Chrome/Edge
+npm run test:firefox # Mozilla Firefox
+npm run test:webkit # Safari
+```
+
+### Special Modes
+```bash
+npm run test:headed # Visible browser
+npm run test:blocked # Block ads/analytics
+npm run snapshots:update # Update visual baselines
+```
+
+### Utilities
+```bash
+npm run report # Open enhanced HTML report
+npm run report:cucumber # Open standard Cucumber report
+npm run clean # Clean all artifacts
+npm run clean:reports # Clean reports only
+npm run clean:snapshots # Clean visual snapshots
+npm run compile # Compile TypeScript
+npm run lint # Run ESLint
+npm run format # Format with Prettier
+```
+
+---
+
+## š Project Structure
+
+```
+playwright-cucumber-framework/
+āāā .github/
+ā āāā workflows/
+ā āāā playwright-tests.yml # CI/CD pipeline
+āāā config/
+ā āāā test.config.ts # Centralized configuration
+āāā src/
+ā āāā accessibility/
+ā ā āāā accessibility-helper.ts # WCAG compliance testing
+ā āāā api/
+ā ā āāā api-client.ts # REST API testing
+ā āāā mobile/
+ā ā āāā mobile-helper.ts # Device emulation
+ā āāā performance/
+ā ā āāā performance-helper.ts # Web Vitals & budgets
+ā āāā utils/
+ā ā āāā error-handler.ts # Error handling
+ā ā āāā logger.ts # Winston logger
+ā āāā visual/
+ā ā āāā visual-testing.ts # Visual regression
+ā āāā web/
+ā āāā actions.ts # Web actions
+ā āāā network-helper.ts # Network mocking
+āāā tests/
+ā āāā data/
+ā ā āāā test-data-manager.ts # Test data management
+ā āāā features/
+ā ā āāā shopping.feature # UI test scenarios
+ā ā āāā api-example.feature # API test scenarios
+ā ā āāā advanced-testing.feature # Advanced features
+ā āāā pages/
+ā ā āāā login.page.ts # Page objects
+ā ā āāā products.page.ts
+ā ā āāā cart.page.ts
+ā āāā steps/
+ā ā āāā shopping.steps.ts # Step definitions
+ā ā āāā api.steps.ts
+ā ā āāā advanced.steps.ts
+ā āāā support/
+ā ā āāā world.ts # Cucumber World
+ā ā āāā hooks.ts # Test hooks
+ā ā āāā custom-reporter.ts # Custom reporter
+ā āāā test-results/ # Test artifacts
+ā āāā reports/
+ā ā āāā cucumber-report.html
+ā ā āāā summary.html
+ā ā āāā enhanced-report.json
+ā āāā screenshots/
+ā ā āāā snapshots/
+ā āāā videos/
+ā āāā traces/
+āāā logs/
+ā āāā error.log
+ā āāā combined.log
+āāā .env.example # Environment template
+āāā .gitignore
+āāā cucumber.config.js
+āāā tsconfig.json
+āāā package.json
+āāā README.md # This file
+āāā TEST_COMMANDS.md # Complete command reference
+āāā PHASE2_FEATURES.md # API & Network features
+āāā PHASE3_FEATURES.md # Visual, Perf, A11y, Mobile
+āāā FRAMEWORK_COMPLETE.md # Final summary
+```
+
+---
+
+## š” Usage Examples
+
+### UI Test Example
+
+**Feature File (`tests/features/shopping.feature`):**
+```gherkin
+@ui @smoke
+Feature: Shopping Cart
+
+ Scenario: Add item to cart
+ Given I am on the Sauce Demo login page
+ When I login with standard user credentials
+ And I add "Sauce Labs Backpack" to cart
+ And I navigate to cart
+ Then I should see "Sauce Labs Backpack" in my cart
+```
+
+**Step Definition (`tests/steps/shopping.steps.ts`):**
+```typescript
+When('I add {string} to cart', async function(this: TestWorld, productName: string) {
+ this.scenarioLogger.step(`Adding product "${productName}" to cart`);
+ await this.productsPage.addToCart(productName);
+ this.scenarioLogger.info(`Product "${productName}" added to cart`);
+});
+```
+
+**Page Object (`tests/pages/products.page.ts`):**
+```typescript
+export class ProductsPage {
+ constructor(private page: Page, private actions: WebActions) {}
+
+ async addToCart(productName: string): Promise {
+ const selector = `//div[text()="${productName}"]//button`;
+ await this.actions.click(selector);
+ }
+}
+```
+
+### API Test Example
+
+**Feature File:**
+```gherkin
+@api @smoke
+Feature: User API
+
+ Scenario: Create new user
+ Given I have a valid API authentication token
+ When I make a POST request to "/api/users" with:
+ | name | John Doe |
+ | email | john@test.com |
+ Then the API response status should be 201
+ And the API response should be valid JSON
+```
+
+**Step Definition:**
+```typescript
+When('I make a POST request to {string} with:', async function(this: TestWorld, endpoint: string, dataTable) {
+ const data = dataTable.rowsHash();
+ const response = await this.apiClient.post(endpoint, data);
+ (this as any).lastApiResponse = response;
+});
+```
+
+### Visual Regression Test
+
+**Feature File:**
+```gherkin
+@visual @regression
+Feature: Visual Regression
+
+ Scenario: Products page visual check
+ Given I am on the products page
+ When I compare the page visually as "products-page"
+ Then all visual comparisons should pass
+```
+
+### Performance Test
+
+**Feature File:**
+```gherkin
+@performance @smoke
+Feature: Page Performance
+
+ Scenario: Page load performance
+ Given I am on the products page
+ When I measure page load performance
+ Then the page should load within 3000ms
+ And the performance score should be at least 80
+```
+
+### Accessibility Test
+
+**Feature File:**
+```gherkin
+@accessibility @wcag
+Feature: Accessibility Compliance
+
+ Scenario: WCAG AA compliance
+ Given I am on the products page
+ When I run an accessibility scan
+ Then there should be no critical accessibility violations
+ And the page should be WCAG "AA" compliant
+```
+
+### Mobile Test
+
+**Feature File:**
+```gherkin
+@mobile @responsive
+Feature: Mobile Testing
+
+ Scenario: iPhone 13 testing
+ Given I am using a "iPhone_13" device
+ And I am on the products page
+ When I tap on ".add-to-cart"
+ Then the item should be added to cart
+```
+
+### Network Mocking
+
+**Step Definition:**
+```typescript
+Given('I mock the API response for {string}', async function(this: TestWorld, urlPattern: string) {
+ await this.networkHelper.mockApiResponse(urlPattern, {
+ status: 200,
+ body: { success: true, data: [...] }
+ });
+});
+```
+
+### Test Data Usage
+
+```typescript
+import { TestData, DataBuilder } from '../data/test-data-manager';
+
+// Static data
+const user = TestData.users.standard;
+await this.loginPage.login(user.username, user.password);
+
+// Dynamic data
+const randomUser = DataBuilder.generateUserCredentials();
+const checkoutInfo = DataBuilder.generateCheckoutInfo();
+```
+
+---
+
+## š Reports and Artifacts
+
+### Available Reports
+
+After running tests, find reports in `test-results/`:
+
+1. **Enhanced HTML Summary** (`reports/summary.html`)
+ - Visual success rate display
+ - Test statistics with charts
+ - Slowest scenarios
+ - Failed scenarios with errors
+ - Test metadata
+
+2. **Standard Cucumber HTML** (`reports/cucumber-report.html`)
+ - Feature-by-feature breakdown
+ - Scenario details
+ - Step execution times
+
+3. **Enhanced JSON** (`reports/enhanced-report.json`)
+ - Machine-readable format
+ - Performance metrics
+ - Budget violations
+ - For custom integrations
+
+4. **Performance Reports** (`performance/performance-report.json`)
+ - Web Vitals metrics
+ - Resource timing
+ - Budget violations
+ - Performance score
+
+5. **Accessibility Reports** (`accessibility/a11y-report.json`)
+ - WCAG violations
+ - Impact levels
+ - Remediation guidance
+ - Accessibility score
+
+### Artifacts on Failure
+
+Automatically captured when tests fail:
+
+- **Screenshots** - `screenshots/*.png`
+- **Videos** - `videos/*.webm`
+- **Traces** - `traces/*.zip` (view with `npx playwright show-trace`)
+- **Visual Diffs** - `screenshots/snapshots/*-diff.png`
+- **Network Logs** - Included in traces
+- **Console Logs** - Attached to Cucumber report
+- **Page HTML** - Attached to report
+
+### Viewing Reports
+
+```bash
+# Open enhanced summary
+npm run report
+
+# Open Cucumber report
+npm run report:cucumber
+
+# View traces
+npx playwright show-trace test-results/traces/scenario.zip
+
+# View logs
+tail -f logs/combined.log
+cat logs/error.log
+```
+
+---
+
+## āļø Configuration
+
+### Environment Variables (`.env`)
+
+```bash
+# Environment
+TEST_ENV=dev # dev, staging, prod, ci
+NODE_ENV=development
+
+# Browser
+BROWSER=chromium # chromium, firefox, webkit
+HEADLESS=false # true, false
+SLOW_MO=0 # Delay in ms
+TIMEOUT=30000 # Default timeout in ms
+
+# Viewport
+VIEWPORT_WIDTH=1920
+VIEWPORT_HEIGHT=1080
+
+# Execution
+PARALLEL_WORKERS=1 # Number of parallel workers
+RETRIES=2 # Test retry count
+
+# Logging
+LOG_LEVEL=info # error, warn, info, debug
+
+# Video Recording
+VIDEO_RECORDING=true
+VIDEO_MODE=retain-on-failure # on, off, retain-on-failure, on-first-retry
+
+# Screenshots
+SCREENSHOT_ON_FAILURE=true
+
+# Tracing
+TRACE_ENABLED=true
+TRACE_MODE=retain-on-failure
+
+# Visual Testing
+VISUAL_THRESHOLD=0.2
+VISUAL_MAX_DIFF_PIXELS=100
+
+# Performance
+PERF_BUDGET_LOAD=3000
+PERF_BUDGET_FCP=1800
+PERF_BUDGET_LCP=2500
+
+# Accessibility
+A11Y_LEVEL=AA # A, AA, AAA
+A11Y_FAIL_ON_VIOLATIONS=false
+
+# Mobile
+DEFAULT_MOBILE_DEVICE=iPhone_13
+MOBILE_NETWORK=4g
+
+# CI/CD
+CI=false
+```
+
+### Test Configuration (`config/test.config.ts`)
+
+The framework automatically loads environment-specific configuration:
+
+```typescript
+// Development
+TEST_ENV=dev npm test
+
+// Staging
+TEST_ENV=staging npm test
+
+// Production
+TEST_ENV=prod npm test
+
+// CI
+TEST_ENV=ci npm run test:ci
+```
+
+---
+
+## š CI/CD Integration
+
+### GitHub Actions (Pre-configured)
+
+The framework includes a ready-to-use GitHub Actions workflow:
+
+```yaml
+# .github/workflows/playwright-tests.yml
+name: Playwright Tests
+on: [push, pull_request, workflow_dispatch]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ browser: [chromium, firefox, webkit]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ - run: npm ci
+ - run: npx playwright install --with-deps
+ - run: npm run test:ci
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: test-results
+ path: test-results/
+```
+
+### Manual Workflow Trigger
+
+1. Go to GitHub Actions tab
+2. Select "Playwright Cucumber Tests"
+3. Click "Run workflow"
+4. Choose:
+ - Environment (dev/staging/prod)
+ - Browser (chromium/firefox/webkit/all)
+ - Parallel workers (1-8)
+
+### Other CI/CD Platforms
+
+**Jenkins:**
+```groovy
+pipeline {
+ agent any
+ stages {
+ stage('Test') {
+ steps {
+ sh 'npm ci'
+ sh 'npx playwright install --with-deps'
+ sh 'npm run test:ci'
+ }
+ }
+ stage('Report') {
+ steps {
+ publishHTML([
+ reportDir: 'test-results/reports',
+ reportFiles: 'summary.html',
+ reportName: 'Test Report'
+ ])
+ }
+ }
+ }
+}
+```
+
+**GitLab CI:**
+```yaml
+test:
+ image: mcr.microsoft.com/playwright:latest
+ script:
+ - npm ci
+ - npm run test:ci
+ artifacts:
+ paths:
+ - test-results/
+ when: always
+```
+
+---
+
+## šÆ Best Practices
+
+### Test Organization
+ā
Group tests by feature/domain
+ā
Use meaningful, descriptive scenario names
+ā
Tag appropriately (@smoke, @regression, @api, etc.)
+ā
Keep scenarios focused and independent
+ā
Use Background for common setup
+
+### Page Objects
+ā
One class per page/component
+ā
Encapsulate all selectors
+ā
Return promises consistently
+ā
Add comprehensive JSDoc comments
+ā
Use meaningful method names
+
+### Test Data
+ā
Use static data for known scenarios
+ā
Generate dynamic data for variety/uniqueness
+ā
Never commit real credentials
+ā
Use environment-specific data
+ā
Keep test data DRY
+
+### Visual Testing
+ā
Mask dynamic content (dates, counters, ads)
+ā
Run on consistent environment
+ā
Update baselines intentionally
+ā
Test multiple viewports
+ā
Use descriptive snapshot names
+
+### Performance Testing
+ā
Set realistic performance budgets
+ā
Test on representative hardware
+ā
Block unnecessary resources
+ā
Monitor trends over time
+ā
Test both cold and warm loads
+
+### Accessibility Testing
+ā
Test every page and component
+ā
Aim for WCAG AA minimum
+ā
Fix critical issues first
+ā
Test with keyboard navigation
+ā
Validate with screen readers
+
+### Mobile Testing
+ā
Test on real device viewports
+ā
Test portrait and landscape
+ā
Emulate real network conditions
+ā
Test touch interactions
+ā
Cover iOS and Android
+
+### CI/CD
+ā
Run smoke tests on every commit
+ā
Run full regression nightly
+ā
Archive test artifacts
+ā
Monitor quality metrics
+ā
Fail builds on critical issues
+
+---
+
+## š Documentation
+
+- **README.md** - This comprehensive guide
+- **TEST_COMMANDS.md** - Complete command reference with examples
+- **PHASE2_FEATURES.md** - API Testing, Network Control, Test Data
+- **PHASE3_FEATURES.md** - Visual, Performance, Accessibility, Mobile
+- **FRAMEWORK_COMPLETE.md** - Final summary and capabilities
+
+---
+
+## š Troubleshooting
+
+### Common Issues
+
+**Browsers not found:**
+```bash
+npx playwright install --with-deps
+```
+
+**TypeScript errors:**
+```bash
+npm run compile
+```
+
+**Tests failing randomly:**
+```bash
+# Increase timeout
+TIMEOUT=60000 npm test
+
+# Reduce parallel workers
+PARALLEL_WORKERS=1 npm test
+
+# Check for race conditions
+```
+
+**Visual tests failing:**
+```bash
+# View diffs
+open test-results/screenshots/snapshots/*-diff.png
+
+# Update baselines if intentional
+npm run snapshots:update
+```
+
+**Performance tests slow:**
+```bash
+# Check network conditions
+# Block unnecessary resources
+npm run test:blocked
+```
+
+**Accessibility violations:**
+```bash
+# View detailed report
+cat test-results/accessibility/a11y-report.json
+
+# Fix critical issues first
+npm run test:accessibility
+```
+
+### Getting Help
+
+1. Check documentation files
+2. Review logs: `logs/combined.log`
+3. View traces: `npx playwright show-trace`
+4. Create GitHub issue with:
+ - Error message
+ - Steps to reproduce
+ - Environment details
+ - Relevant logs
+
+---
+
+## š¤ Contributing
+
+Contributions are welcome! Please follow these steps:
+
+1. Fork the repository
+2. Create a feature branch (`git checkout -b feature/amazing-feature`)
+3. Make your changes
+4. Run tests (`npm test`)
+5. Commit your changes (`git commit -m 'Add amazing feature'`)
+6. Push to the branch (`git push origin feature/amazing-feature`)
+7. Open a Pull Request
+
+### Contribution Guidelines
+
+- ā
All tests must pass
+- ā
Follow TypeScript best practices
+- ā
Add tests for new features
+- ā
Update documentation
+- ā
Follow existing code style
+- ā
Write clear commit messages
+
+---
+
+## š License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+---
+
+## š Acknowledgments
+
+- [Playwright](https://playwright.dev/) - Modern browser automation
+- [Cucumber](https://cucumber.io/) - BDD framework
+- [TypeScript](https://www.typescriptlang.org/) - Type-safe JavaScript
+- [Winston](https://github.com/winstonjs/winston) - Logging library
+- [axe-core](https://github.com/dequelabs/axe-core) - Accessibility testing
+
+---
+
+## š Support
+
+- š§ **Email**: your.email@example.com
+- š **Issues**: [GitHub Issues](https://github.com/yourusername/playwright-cucumber-framework/issues)
+- š¬ **Discussions**: [GitHub Discussions](https://github.com/yourusername/playwright-cucumber-framework/discussions)
+- š **Documentation**: See documentation files in repository
+
+---
+
+## š Quick Start Summary
+
+```bash
+# 1. Install
+npm install
+npx playwright install
+
+# 2. Configure
+cp .env.example .env
+
+# 3. Run tests
+npm test
+
+# 4. View reports
+npm run report
+
+# 5. Try advanced features
+npm run test:visual
+npm run test:performance
+npm run test:accessibility
+npm run test:mobile
+```
+
+---
+
+## š Framework Capabilities
+
+| Feature | Status | Documentation |
+|---------|--------|---------------|
+| UI Testing | ā
Complete | README.md |
+| API Testing | ā
Complete | PHASE2_FEATURES.md |
+| Visual Regression | ā
Complete | PHASE3_FEATURES.md |
+| Performance Testing | ā
Complete | PHASE3_FEATURES.md |
+| Accessibility Testing | ā
Complete | PHASE3_FEATURES.md |
+| Mobile Testing | ā
Complete | PHASE3_FEATURES.md |
+| Network Control | ā
Complete | PHASE2_FEATURES.md |
+| Test Data Management | ā
Complete | PHASE2_FEATURES.md |
+| Parallel Execution | ā
Complete | README.md |
+| CI/CD Integration | ā
Complete | README.md |
+| Enhanced Reporting | ā
Complete | README.md |
+
+---
+
+
+
+**Built with ā¤ļø by QA Engineers, for QA Engineers**
+
+If this framework helped you, please consider giving it a āļø
+
+**[Documentation](./README.md)** ⢠**[Commands](./TEST_COMMANDS.md)** ⢠**[Examples](./tests/features/)** ⢠**[Contributing](#-contributing)**
+
+---
+
+*Framework Version: 1.0.0*
+*Last Updated: 2025-11-05*
+*Status: Production Ready ā
*
+
+
\ No newline at end of file
diff --git a/config/test.config.ts b/config/test.config.ts
new file mode 100644
index 0000000..1afddf2
--- /dev/null
+++ b/config/test.config.ts
@@ -0,0 +1,156 @@
+import { LaunchOptions, BrowserContextOptions } from '@playwright/test';
+
+export interface TestConfig {
+ baseURL: string;
+ browser: 'chromium' | 'firefox' | 'webkit';
+ headless: boolean;
+ slowMo: number;
+ timeout: number;
+ retries: number;
+ viewport: {
+ width: number;
+ height: number;
+ };
+ video: {
+ enabled: boolean;
+ dir: string;
+ mode: 'on' | 'off' | 'retain-on-failure' | 'on-first-retry';
+ };
+ screenshot: {
+ enabled: boolean;
+ dir: string;
+ mode: 'on' | 'off' | 'only-on-failure';
+ };
+ trace: {
+ enabled: boolean;
+ dir: string;
+ mode: 'on' | 'off' | 'retain-on-failure' | 'on-first-retry';
+ };
+ logs: {
+ dir: string;
+ level: 'error' | 'warn' | 'info' | 'debug';
+ };
+}
+
+// Load environment-specific config
+function getEnvironment(): string {
+ return process.env.TEST_ENV || process.env.NODE_ENV || 'dev';
+}
+
+// Base configuration
+const baseConfig: TestConfig = {
+ baseURL: process.env.BASE_URL || 'https://www.saucedemo.com/',
+ browser: (process.env.BROWSER as any) || 'chromium',
+ headless: process.env.HEADLESS === 'true',
+ slowMo: parseInt(process.env.SLOW_MO || '0'),
+ timeout: parseInt(process.env.TIMEOUT || '30000'),
+ retries: parseInt(process.env.RETRIES || '2'),
+ viewport: {
+ width: parseInt(process.env.VIEWPORT_WIDTH || '1920'),
+ height: parseInt(process.env.VIEWPORT_HEIGHT || '1080')
+ },
+ video: {
+ enabled: process.env.VIDEO_RECORDING !== 'false',
+ dir: 'test-results/videos',
+ mode: (process.env.VIDEO_MODE as any) || 'retain-on-failure'
+ },
+ screenshot: {
+ enabled: process.env.SCREENSHOT_ON_FAILURE !== 'false',
+ dir: 'test-results/screenshots',
+ mode: 'only-on-failure'
+ },
+ trace: {
+ enabled: process.env.TRACE_ENABLED !== 'false',
+ dir: 'test-results/traces',
+ mode: (process.env.TRACE_MODE as any) || 'retain-on-failure'
+ },
+ logs: {
+ dir: 'logs',
+ level: (process.env.LOG_LEVEL as any) || 'info'
+ }
+};
+
+// Environment-specific overrides
+const environments: Record> = {
+ dev: {
+ baseURL: 'https://www.saucedemo.com/',
+ headless: false,
+ slowMo: 50,
+ retries: 0
+ },
+ staging: {
+ baseURL: process.env.STAGING_URL || 'https://staging.saucedemo.com/',
+ headless: true,
+ retries: 1
+ },
+ prod: {
+ baseURL: process.env.PROD_URL || 'https://www.saucedemo.com/',
+ headless: true,
+ retries: 2
+ },
+ ci: {
+ baseURL: process.env.BASE_URL || 'https://www.saucedemo.com/',
+ headless: true,
+ slowMo: 0,
+ retries: 2,
+ video: {
+ enabled: true,
+ dir: 'test-results/videos',
+ mode: 'on-first-retry'
+ }
+ }
+};
+
+// Merge base config with environment-specific config
+function getConfig(): TestConfig {
+ const env = getEnvironment();
+ const envConfig = environments[env] || {};
+
+ return {
+ ...baseConfig,
+ ...envConfig,
+ viewport: { ...baseConfig.viewport, ...envConfig.viewport },
+ video: { ...baseConfig.video, ...envConfig.video },
+ screenshot: { ...baseConfig.screenshot, ...envConfig.screenshot },
+ trace: { ...baseConfig.trace, ...envConfig.trace },
+ logs: { ...baseConfig.logs, ...envConfig.logs }
+ };
+}
+
+export const config = getConfig();
+
+// Playwright-specific options
+export function getBrowserLaunchOptions(): LaunchOptions {
+ return {
+ headless: config.headless,
+ slowMo: config.slowMo,
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage'
+ ]
+ };
+}
+
+export function getBrowserContextOptions(): BrowserContextOptions {
+ return {
+ viewport: config.viewport,
+ recordVideo: config.video.enabled ? {
+ dir: config.video.dir,
+ size: config.viewport
+ } : undefined,
+ ignoreHTTPSErrors: true,
+ locale: 'en-US',
+ timezoneId: 'America/New_York'
+ };
+}
+
+// Log configuration on load
+console.log(`\nš§ Test Configuration (${getEnvironment()}):`);
+console.log(` Base URL: ${config.baseURL}`);
+console.log(` Browser: ${config.browser}`);
+console.log(` Headless: ${config.headless}`);
+console.log(` Parallel Workers: ${process.env.PARALLEL_WORKERS || 1}`);
+console.log(` Video Recording: ${config.video.enabled ? config.video.mode : 'disabled'}`);
+console.log(` Screenshots: ${config.screenshot.enabled ? config.screenshot.mode : 'disabled'}`);
+console.log(` Tracing: ${config.trace.enabled ? config.trace.mode : 'disabled'}\n`);
\ No newline at end of file
diff --git a/dist/config/test.config.d.ts b/dist/config/test.config.d.ts
new file mode 100644
index 0000000..997aa6d
--- /dev/null
+++ b/dist/config/test.config.d.ts
@@ -0,0 +1,35 @@
+import { LaunchOptions, BrowserContextOptions } from '@playwright/test';
+export interface TestConfig {
+ baseURL: string;
+ browser: 'chromium' | 'firefox' | 'webkit';
+ headless: boolean;
+ slowMo: number;
+ timeout: number;
+ retries: number;
+ viewport: {
+ width: number;
+ height: number;
+ };
+ video: {
+ enabled: boolean;
+ dir: string;
+ mode: 'on' | 'off' | 'retain-on-failure' | 'on-first-retry';
+ };
+ screenshot: {
+ enabled: boolean;
+ dir: string;
+ mode: 'on' | 'off' | 'only-on-failure';
+ };
+ trace: {
+ enabled: boolean;
+ dir: string;
+ mode: 'on' | 'off' | 'retain-on-failure' | 'on-first-retry';
+ };
+ logs: {
+ dir: string;
+ level: 'error' | 'warn' | 'info' | 'debug';
+ };
+}
+export declare const config: TestConfig;
+export declare function getBrowserLaunchOptions(): LaunchOptions;
+export declare function getBrowserContextOptions(): BrowserContextOptions;
diff --git a/dist/config/test.config.js b/dist/config/test.config.js
new file mode 100644
index 0000000..e1166bb
--- /dev/null
+++ b/dist/config/test.config.js
@@ -0,0 +1,119 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.config = void 0;
+exports.getBrowserLaunchOptions = getBrowserLaunchOptions;
+exports.getBrowserContextOptions = getBrowserContextOptions;
+// Load environment-specific config
+function getEnvironment() {
+ return process.env.TEST_ENV || process.env.NODE_ENV || 'dev';
+}
+// Base configuration
+const baseConfig = {
+ baseURL: process.env.BASE_URL || 'https://www.saucedemo.com/',
+ browser: process.env.BROWSER || 'chromium',
+ headless: process.env.HEADLESS === 'true',
+ slowMo: parseInt(process.env.SLOW_MO || '0'),
+ timeout: parseInt(process.env.TIMEOUT || '30000'),
+ retries: parseInt(process.env.RETRIES || '2'),
+ viewport: {
+ width: parseInt(process.env.VIEWPORT_WIDTH || '1920'),
+ height: parseInt(process.env.VIEWPORT_HEIGHT || '1080')
+ },
+ video: {
+ enabled: process.env.VIDEO_RECORDING !== 'false',
+ dir: 'test-results/videos',
+ mode: process.env.VIDEO_MODE || 'retain-on-failure'
+ },
+ screenshot: {
+ enabled: process.env.SCREENSHOT_ON_FAILURE !== 'false',
+ dir: 'test-results/screenshots',
+ mode: 'only-on-failure'
+ },
+ trace: {
+ enabled: process.env.TRACE_ENABLED !== 'false',
+ dir: 'test-results/traces',
+ mode: process.env.TRACE_MODE || 'retain-on-failure'
+ },
+ logs: {
+ dir: 'logs',
+ level: process.env.LOG_LEVEL || 'info'
+ }
+};
+// Environment-specific overrides
+const environments = {
+ dev: {
+ baseURL: 'https://www.saucedemo.com/',
+ headless: false,
+ slowMo: 50,
+ retries: 0
+ },
+ staging: {
+ baseURL: process.env.STAGING_URL || 'https://staging.saucedemo.com/',
+ headless: true,
+ retries: 1
+ },
+ prod: {
+ baseURL: process.env.PROD_URL || 'https://www.saucedemo.com/',
+ headless: true,
+ retries: 2
+ },
+ ci: {
+ baseURL: process.env.BASE_URL || 'https://www.saucedemo.com/',
+ headless: true,
+ slowMo: 0,
+ retries: 2,
+ video: {
+ enabled: true,
+ dir: 'test-results/videos',
+ mode: 'on-first-retry'
+ }
+ }
+};
+// Merge base config with environment-specific config
+function getConfig() {
+ const env = getEnvironment();
+ const envConfig = environments[env] || {};
+ return {
+ ...baseConfig,
+ ...envConfig,
+ viewport: { ...baseConfig.viewport, ...envConfig.viewport },
+ video: { ...baseConfig.video, ...envConfig.video },
+ screenshot: { ...baseConfig.screenshot, ...envConfig.screenshot },
+ trace: { ...baseConfig.trace, ...envConfig.trace },
+ logs: { ...baseConfig.logs, ...envConfig.logs }
+ };
+}
+exports.config = getConfig();
+// Playwright-specific options
+function getBrowserLaunchOptions() {
+ return {
+ headless: exports.config.headless,
+ slowMo: exports.config.slowMo,
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage'
+ ]
+ };
+}
+function getBrowserContextOptions() {
+ return {
+ viewport: exports.config.viewport,
+ recordVideo: exports.config.video.enabled ? {
+ dir: exports.config.video.dir,
+ size: exports.config.viewport
+ } : undefined,
+ ignoreHTTPSErrors: true,
+ locale: 'en-US',
+ timezoneId: 'America/New_York'
+ };
+}
+// Log configuration on load
+console.log(`\nš§ Test Configuration (${getEnvironment()}):`);
+console.log(` Base URL: ${exports.config.baseURL}`);
+console.log(` Browser: ${exports.config.browser}`);
+console.log(` Headless: ${exports.config.headless}`);
+console.log(` Parallel Workers: ${process.env.PARALLEL_WORKERS || 1}`);
+console.log(` Video Recording: ${exports.config.video.enabled ? exports.config.video.mode : 'disabled'}`);
+console.log(` Screenshots: ${exports.config.screenshot.enabled ? exports.config.screenshot.mode : 'disabled'}`);
+console.log(` Tracing: ${exports.config.trace.enabled ? exports.config.trace.mode : 'disabled'}\n`);
diff --git a/dist/src/reporting/logger.d.ts b/dist/src/reporting/logger.d.ts
new file mode 100644
index 0000000..ea7b326
--- /dev/null
+++ b/dist/src/reporting/logger.d.ts
@@ -0,0 +1,9 @@
+export declare class Logger {
+ private logger;
+ constructor(context: string);
+ info(message: string, meta?: Record): void;
+ error(message: string, error?: Error, meta?: Record): void;
+ warn(message: string, meta?: Record): void;
+ debug(message: string, meta?: Record): void;
+ trace(message: string, meta?: Record): void;
+}
diff --git a/dist/src/reporting/logger.js b/dist/src/reporting/logger.js
new file mode 100644
index 0000000..e0cddd5
--- /dev/null
+++ b/dist/src/reporting/logger.js
@@ -0,0 +1,55 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Logger = void 0;
+const winston_1 = __importDefault(require("winston"));
+class Logger {
+ constructor(context) {
+ this.logger = winston_1.default.createLogger({
+ level: process.env.LOG_LEVEL || 'info',
+ format: winston_1.default.format.combine(winston_1.default.format.timestamp(), winston_1.default.format.json(), winston_1.default.format.printf(({ timestamp, level, message, ...meta }) => {
+ return JSON.stringify({
+ timestamp,
+ level,
+ context,
+ message,
+ ...meta,
+ });
+ })),
+ transports: [
+ new winston_1.default.transports.Console({
+ format: winston_1.default.format.combine(winston_1.default.format.colorize(), winston_1.default.format.simple()),
+ }),
+ new winston_1.default.transports.File({
+ filename: 'logs/error.log',
+ level: 'error',
+ }),
+ new winston_1.default.transports.File({
+ filename: 'logs/combined.log',
+ }),
+ ],
+ });
+ }
+ info(message, meta) {
+ this.logger.info(message, meta);
+ }
+ error(message, error, meta) {
+ this.logger.error(message, {
+ error: error?.message,
+ stack: error?.stack,
+ ...meta,
+ });
+ }
+ warn(message, meta) {
+ this.logger.warn(message, meta);
+ }
+ debug(message, meta) {
+ this.logger.debug(message, meta);
+ }
+ trace(message, meta) {
+ this.logger.silly(message, meta);
+ }
+}
+exports.Logger = Logger;
diff --git a/dist/src/utils/error-handler.d.ts b/dist/src/utils/error-handler.d.ts
new file mode 100644
index 0000000..8a8db07
--- /dev/null
+++ b/dist/src/utils/error-handler.d.ts
@@ -0,0 +1,77 @@
+import { Page } from '@playwright/test';
+export interface ErrorContext {
+ action: string;
+ selector?: string;
+ url?: string;
+ additionalInfo?: any;
+}
+export declare class TestError extends Error {
+ context: ErrorContext;
+ originalError?: Error | undefined;
+ constructor(message: string, context: ErrorContext, originalError?: Error | undefined);
+}
+export declare class ErrorHandler {
+ private page;
+ constructor(page: Page);
+ /**
+ * Wrap an action with error handling
+ */
+ wrapAction(action: () => Promise, context: ErrorContext): Promise;
+ /**
+ * Handle errors with enhanced logging and diagnostics
+ */
+ handleError(error: any, context: ErrorContext): Promise;
+ /**
+ * Capture diagnostic information about the page state
+ */
+ captureDiagnostics(): Promise<{
+ url: string;
+ title: string;
+ viewport: {
+ width: number;
+ height: number;
+ } | null;
+ cookies: import("playwright-core").Cookie[];
+ localStorage: string;
+ sessionStorage: string;
+ userAgent: string;
+ timestamp: string;
+ error?: undefined;
+ } | {
+ error: string;
+ url?: undefined;
+ title?: undefined;
+ viewport?: undefined;
+ cookies?: undefined;
+ localStorage?: undefined;
+ sessionStorage?: undefined;
+ userAgent?: undefined;
+ timestamp?: undefined;
+ }>;
+ /**
+ * Retry an action with exponential backoff
+ */
+ retryAction(action: () => Promise, options: {
+ maxRetries?: number;
+ initialDelay?: number;
+ maxDelay?: number;
+ context: ErrorContext;
+ }): Promise;
+ /**
+ * Assert condition with custom error message
+ */
+ assertCondition(condition: () => Promise, errorMessage: string, context: ErrorContext): Promise;
+ /**
+ * Wait for condition with timeout
+ */
+ waitForCondition(condition: () => Promise, options: {
+ timeout?: number;
+ interval?: number;
+ errorMessage?: string;
+ context: ErrorContext;
+ }): Promise;
+}
+/**
+ * Global error handler for uncaught exceptions
+ */
+export declare function setupGlobalErrorHandlers(): void;
diff --git a/dist/src/utils/error-handler.js b/dist/src/utils/error-handler.js
new file mode 100644
index 0000000..daf61a6
--- /dev/null
+++ b/dist/src/utils/error-handler.js
@@ -0,0 +1,168 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ErrorHandler = exports.TestError = void 0;
+exports.setupGlobalErrorHandlers = setupGlobalErrorHandlers;
+const logger_1 = require("./logger");
+class TestError extends Error {
+ constructor(message, context, originalError) {
+ super(message);
+ this.context = context;
+ this.originalError = originalError;
+ this.name = 'TestError';
+ // Capture stack trace
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, TestError);
+ }
+ }
+}
+exports.TestError = TestError;
+class ErrorHandler {
+ constructor(page) {
+ this.page = page;
+ }
+ /**
+ * Wrap an action with error handling
+ */
+ async wrapAction(action, context) {
+ try {
+ return await action();
+ }
+ catch (error) {
+ await this.handleError(error, context);
+ throw error;
+ }
+ }
+ /**
+ * Handle errors with enhanced logging and diagnostics
+ */
+ async handleError(error, context) {
+ const errorMessage = error.message || 'Unknown error';
+ logger_1.logger.error('Test action failed', {
+ action: context.action,
+ selector: context.selector,
+ url: await this.page.url(),
+ error: errorMessage,
+ stack: error.stack,
+ ...context.additionalInfo
+ });
+ // Capture diagnostic information
+ try {
+ const diagnostics = await this.captureDiagnostics();
+ logger_1.logger.debug('Diagnostics captured', diagnostics);
+ }
+ catch (diagError) {
+ logger_1.logger.warn('Failed to capture diagnostics', { error: diagError });
+ }
+ }
+ /**
+ * Capture diagnostic information about the page state
+ */
+ async captureDiagnostics() {
+ try {
+ return {
+ url: this.page.url(),
+ title: await this.page.title(),
+ viewport: this.page.viewportSize(),
+ cookies: await this.page.context().cookies(),
+ localStorage: await this.page.evaluate(() => JSON.stringify(localStorage)),
+ sessionStorage: await this.page.evaluate(() => JSON.stringify(sessionStorage)),
+ userAgent: await this.page.evaluate(() => navigator.userAgent),
+ timestamp: new Date().toISOString()
+ };
+ }
+ catch (error) {
+ return { error: 'Failed to capture diagnostics' };
+ }
+ }
+ /**
+ * Retry an action with exponential backoff
+ */
+ async retryAction(action, options) {
+ const maxRetries = options.maxRetries || 3;
+ const initialDelay = options.initialDelay || 1000;
+ const maxDelay = options.maxDelay || 10000;
+ let lastError;
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ try {
+ logger_1.logger.debug(`Attempting action (${attempt}/${maxRetries})`, options.context);
+ return await action();
+ }
+ catch (error) {
+ lastError = error;
+ if (attempt === maxRetries) {
+ logger_1.logger.error('Max retries reached', {
+ ...options.context,
+ attempts: attempt,
+ error: lastError.message
+ });
+ break;
+ }
+ const delay = Math.min(initialDelay * Math.pow(2, attempt - 1), maxDelay);
+ logger_1.logger.warn(`Action failed, retrying in ${delay}ms`, {
+ ...options.context,
+ attempt,
+ error: lastError.message
+ });
+ await this.page.waitForTimeout(delay);
+ }
+ }
+ throw new TestError(`Action failed after ${maxRetries} attempts: ${lastError?.message}`, options.context, lastError);
+ }
+ /**
+ * Assert condition with custom error message
+ */
+ async assertCondition(condition, errorMessage, context) {
+ try {
+ const result = await condition();
+ if (!result) {
+ throw new TestError(errorMessage, context);
+ }
+ }
+ catch (error) {
+ if (error instanceof TestError) {
+ throw error;
+ }
+ throw new TestError(errorMessage, context, error);
+ }
+ }
+ /**
+ * Wait for condition with timeout
+ */
+ async waitForCondition(condition, options) {
+ const timeout = options.timeout || 30000;
+ const interval = options.interval || 500;
+ const errorMessage = options.errorMessage || 'Condition not met within timeout';
+ const startTime = Date.now();
+ while (Date.now() - startTime < timeout) {
+ try {
+ if (await condition()) {
+ return;
+ }
+ }
+ catch (error) {
+ // Ignore errors during polling
+ }
+ await this.page.waitForTimeout(interval);
+ }
+ throw new TestError(errorMessage, options.context);
+ }
+}
+exports.ErrorHandler = ErrorHandler;
+/**
+ * Global error handler for uncaught exceptions
+ */
+function setupGlobalErrorHandlers() {
+ process.on('unhandledRejection', (reason, promise) => {
+ logger_1.logger.error('Unhandled Promise Rejection', {
+ reason,
+ promise
+ });
+ });
+ process.on('uncaughtException', (error) => {
+ logger_1.logger.error('Uncaught Exception', {
+ error: error.message,
+ stack: error.stack
+ });
+ process.exit(1);
+ });
+}
diff --git a/dist/src/utils/logger.d.ts b/dist/src/utils/logger.d.ts
new file mode 100644
index 0000000..fc23e69
--- /dev/null
+++ b/dist/src/utils/logger.d.ts
@@ -0,0 +1,21 @@
+import winston from 'winston';
+export declare const logger: winston.Logger;
+export declare class ScenarioLogger {
+ private scenarioName;
+ constructor(scenarioName: string);
+ info(message: string, meta?: any): void;
+ warn(message: string, meta?: any): void;
+ error(message: string, meta?: any): void;
+ debug(message: string, meta?: any): void;
+ step(stepText: string): void;
+ action(actionName: string, details?: any): void;
+ assertion(description: string, details?: any): void;
+}
+export declare function logTestSummary(stats: {
+ total: number;
+ passed: number;
+ failed: number;
+ skipped: number;
+ duration: number;
+}): void;
+export default logger;
diff --git a/dist/src/utils/logger.js b/dist/src/utils/logger.js
new file mode 100644
index 0000000..3dcdfc3
--- /dev/null
+++ b/dist/src/utils/logger.js
@@ -0,0 +1,130 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ScenarioLogger = exports.logger = void 0;
+exports.logTestSummary = logTestSummary;
+const winston_1 = __importDefault(require("winston"));
+const path_1 = __importDefault(require("path"));
+const fs_1 = __importDefault(require("fs"));
+const test_config_1 = require("../../config/test.config");
+// Ensure logs directory exists
+const logsDir = test_config_1.config.logs.dir;
+if (!fs_1.default.existsSync(logsDir)) {
+ fs_1.default.mkdirSync(logsDir, { recursive: true });
+}
+// Custom format for console output with better readability
+const consoleFormat = winston_1.default.format.combine(winston_1.default.format.timestamp({ format: 'HH:mm:ss' }), winston_1.default.format.colorize(), winston_1.default.format.printf((info) => {
+ const { timestamp, level, message, scenario, action, selector, url, ...meta } = info;
+ let output = `[${timestamp}] ${level}:`;
+ // Add scenario name if present
+ if (scenario) {
+ output += ` [${scenario}]`;
+ }
+ // Add the main message
+ output += ` ${message}`;
+ // Add relevant metadata inline
+ if (action)
+ output += ` | Action: ${action}`;
+ if (selector)
+ output += ` | Element: ${selector}`;
+ if (url && typeof message === 'string' && message.toLowerCase().includes('url')) {
+ output += ` | URL: ${url}`;
+ }
+ // Add remaining metadata on new lines if needed
+ const remainingMeta = { ...meta };
+ delete remainingMeta.service;
+ delete remainingMeta.timestamp;
+ if (Object.keys(remainingMeta).length > 0) {
+ const metaStr = Object.entries(remainingMeta)
+ .map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)
+ .join('\n');
+ if (metaStr)
+ output += '\n' + metaStr;
+ }
+ return output;
+}));
+// Format for file output (JSON for parsing)
+const fileFormat = winston_1.default.format.combine(winston_1.default.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston_1.default.format.errors({ stack: true }), winston_1.default.format.json());
+// Create the logger
+exports.logger = winston_1.default.createLogger({
+ level: test_config_1.config.logs.level,
+ format: fileFormat,
+ defaultMeta: { service: 'playwright-cucumber-tests' },
+ transports: [
+ // Error logs
+ new winston_1.default.transports.File({
+ filename: path_1.default.join(logsDir, 'error.log'),
+ level: 'error',
+ maxsize: 5242880, // 5MB
+ maxFiles: 5
+ }),
+ // Combined logs
+ new winston_1.default.transports.File({
+ filename: path_1.default.join(logsDir, 'combined.log'),
+ maxsize: 5242880, // 5MB
+ maxFiles: 5
+ }),
+ // Console output with better formatting
+ new winston_1.default.transports.Console({
+ format: consoleFormat,
+ level: process.env.CI === 'true' ? 'warn' : test_config_1.config.logs.level
+ })
+ ]
+});
+// Create scenario-specific logger
+class ScenarioLogger {
+ constructor(scenarioName) {
+ this.scenarioName = scenarioName;
+ }
+ info(message, meta) {
+ exports.logger.info(message, { scenario: this.scenarioName, ...meta });
+ }
+ warn(message, meta) {
+ exports.logger.warn(message, { scenario: this.scenarioName, ...meta });
+ }
+ error(message, meta) {
+ exports.logger.error(message, { scenario: this.scenarioName, ...meta });
+ }
+ debug(message, meta) {
+ exports.logger.debug(message, { scenario: this.scenarioName, ...meta });
+ }
+ step(stepText) {
+ // Use a distinctive format for steps
+ exports.logger.info(`š STEP: ${stepText}`, {
+ scenario: this.scenarioName,
+ type: 'step'
+ });
+ }
+ action(actionName, details) {
+ exports.logger.info(`š§ ${actionName}`, {
+ scenario: this.scenarioName,
+ type: 'action',
+ ...details
+ });
+ }
+ assertion(description, details) {
+ exports.logger.info(`ā ${description}`, {
+ scenario: this.scenarioName,
+ type: 'assertion',
+ ...details
+ });
+ }
+}
+exports.ScenarioLogger = ScenarioLogger;
+// Helper to log test execution summary
+function logTestSummary(stats) {
+ const line = 'ā'.repeat(80);
+ exports.logger.info(line);
+ exports.logger.info('š TEST EXECUTION SUMMARY');
+ exports.logger.info(line);
+ exports.logger.info(`Total Scenarios: ${stats.total}`);
+ exports.logger.info(`ā
Passed: ${stats.passed}`);
+ exports.logger.info(`ā Failed: ${stats.failed}`);
+ exports.logger.info(`āļø Skipped: ${stats.skipped}`);
+ exports.logger.info(`ā±ļø Duration: ${stats.duration}ms`);
+ exports.logger.info(line);
+}
+// Export default logger
+exports.default = exports.logger;
diff --git a/dist/src/web/actions.d.ts b/dist/src/web/actions.d.ts
new file mode 100644
index 0000000..938bd10
--- /dev/null
+++ b/dist/src/web/actions.d.ts
@@ -0,0 +1,126 @@
+import { Page, Locator } from '@playwright/test';
+export declare class WebActions {
+ private page;
+ private errorHandler;
+ constructor(page: Page);
+ /**
+ * Navigate to a URL
+ */
+ navigateTo(url: string, options?: {
+ waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
+ }): Promise;
+ /**
+ * Click on an element
+ */
+ click(selector: string, options?: {
+ timeout?: number;
+ force?: boolean;
+ }): Promise;
+ /**
+ * Fill an input field
+ */
+ fill(selector: string, value: string, options?: {
+ timeout?: number;
+ clear?: boolean;
+ }): Promise;
+ /**
+ * Type text with delay (simulates human typing)
+ */
+ type(selector: string, text: string, options?: {
+ delay?: number;
+ }): Promise;
+ /**
+ * Select an option from a dropdown
+ */
+ select(selector: string, value: string | string[]): Promise;
+ /**
+ * Wait for an element to be visible
+ */
+ waitForSelector(selector: string, options?: {
+ timeout?: number;
+ state?: 'visible' | 'hidden' | 'attached';
+ }): Promise;
+ /**
+ * Wait for a specific amount of time
+ */
+ waitForTimeout(timeout: number): Promise;
+ /**
+ * Wait for page load
+ */
+ waitForPageLoad(): Promise;
+ /**
+ * Wait for network idle
+ */
+ waitForNetworkIdle(): Promise;
+ /**
+ * Get text content of an element
+ */
+ getText(selector: string): Promise;
+ /**
+ * Get attribute value of an element
+ */
+ getAttribute(selector: string, attribute: string): Promise;
+ /**
+ * Check if element is visible
+ */
+ isVisible(selector: string, timeout?: number): Promise;
+ /**
+ * Check if element is enabled
+ */
+ isEnabled(selector: string): Promise;
+ /**
+ * Check if checkbox/radio is checked
+ */
+ isChecked(selector: string): Promise;
+ /**
+ * Hover over an element
+ */
+ hover(selector: string): Promise;
+ /**
+ * Double click an element
+ */
+ doubleClick(selector: string): Promise;
+ /**
+ * Press a keyboard key
+ */
+ press(selector: string, key: string): Promise;
+ /**
+ * Scroll to an element
+ */
+ scrollToElement(selector: string): Promise;
+ /**
+ * Take a screenshot
+ */
+ screenshot(options?: {
+ path?: string;
+ fullPage?: boolean;
+ }): Promise;
+ /**
+ * Get current URL
+ */
+ getCurrentUrl(): string;
+ /**
+ * Get page title
+ */
+ getTitle(): Promise;
+ /**
+ * Reload the page
+ */
+ reload(): Promise;
+ /**
+ * Go back in browser history
+ */
+ goBack(): Promise;
+ /**
+ * Go forward in browser history
+ */
+ goForward(): Promise;
+ /**
+ * Execute JavaScript in the browser
+ */
+ evaluate(script: string | Function, ...args: any[]): Promise;
+ /**
+ * Get locator for advanced operations
+ */
+ getLocator(selector: string): Locator;
+}
diff --git a/dist/src/web/actions.js b/dist/src/web/actions.js
new file mode 100644
index 0000000..013d70b
--- /dev/null
+++ b/dist/src/web/actions.js
@@ -0,0 +1,275 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.WebActions = void 0;
+const error_handler_1 = require("../utils/error-handler");
+const logger_1 = require("../utils/logger");
+class WebActions {
+ constructor(page) {
+ this.page = page;
+ this.errorHandler = new error_handler_1.ErrorHandler(page);
+ }
+ /**
+ * Navigate to a URL
+ */
+ async navigateTo(url, options) {
+ const context = { action: 'navigate', url };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`š Navigating to URL`, { url });
+ await this.page.goto(url, {
+ waitUntil: options?.waitUntil || 'domcontentloaded',
+ timeout: 30000
+ });
+ logger_1.logger.debug(`ā Page loaded successfully`, { url });
+ }, context);
+ }
+ /**
+ * Click on an element
+ */
+ async click(selector, options) {
+ const context = { action: 'click', selector };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`š±ļø Clicking element`, { selector });
+ await this.page.locator(selector).click({
+ timeout: options?.timeout || 10000,
+ force: options?.force
+ });
+ logger_1.logger.debug(`ā Click successful`, { selector });
+ }, context);
+ }
+ /**
+ * Fill an input field
+ */
+ async fill(selector, value, options) {
+ const context = { action: 'fill', selector, additionalInfo: { value: '***' } };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`āØļø Filling input field`, { selector, valueLength: value.length });
+ if (options?.clear) {
+ await this.page.locator(selector).clear();
+ }
+ await this.page.locator(selector).fill(value, {
+ timeout: options?.timeout || 10000
+ });
+ logger_1.logger.debug(`ā Input filled`, { selector });
+ }, context);
+ }
+ /**
+ * Type text with delay (simulates human typing)
+ */
+ async type(selector, text, options) {
+ const context = { action: 'type', selector };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`Typing into element: ${selector}`);
+ await this.page.locator(selector).pressSequentially(text, {
+ delay: options?.delay || 50
+ });
+ logger_1.logger.debug(`Successfully typed into: ${selector}`);
+ }, context);
+ }
+ /**
+ * Select an option from a dropdown
+ */
+ async select(selector, value) {
+ const context = { action: 'select', selector };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`Selecting option in: ${selector}`);
+ await this.page.locator(selector).selectOption(value);
+ logger_1.logger.debug(`Successfully selected option in: ${selector}`);
+ }, context);
+ }
+ /**
+ * Wait for an element to be visible
+ */
+ async waitForSelector(selector, options) {
+ const context = { action: 'waitForSelector', selector };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`ā³ Waiting for element`, { selector, state: options?.state || 'visible' });
+ await this.page.locator(selector).waitFor({
+ state: options?.state || 'visible',
+ timeout: options?.timeout || 30000
+ });
+ logger_1.logger.debug(`ā Element ready`, { selector });
+ }, context);
+ }
+ /**
+ * Wait for a specific amount of time
+ */
+ async waitForTimeout(timeout) {
+ logger_1.logger.debug(`Waiting for ${timeout}ms`);
+ await this.page.waitForTimeout(timeout);
+ }
+ /**
+ * Wait for page load
+ */
+ async waitForPageLoad() {
+ const context = { action: 'waitForPageLoad' };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug('Waiting for page load');
+ await this.page.waitForLoadState('domcontentloaded');
+ logger_1.logger.debug('Page loaded');
+ }, context);
+ }
+ /**
+ * Wait for network idle
+ */
+ async waitForNetworkIdle() {
+ const context = { action: 'waitForNetworkIdle' };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug('Waiting for network idle');
+ await this.page.waitForLoadState('networkidle');
+ logger_1.logger.debug('Network is idle');
+ }, context);
+ }
+ /**
+ * Get text content of an element
+ */
+ async getText(selector) {
+ const context = { action: 'getText', selector };
+ return await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`Getting text from: ${selector}`);
+ const text = await this.page.locator(selector).textContent();
+ logger_1.logger.debug(`Text retrieved: ${text?.substring(0, 50)}`);
+ return text;
+ }, context);
+ }
+ /**
+ * Get attribute value of an element
+ */
+ async getAttribute(selector, attribute) {
+ const context = { action: 'getAttribute', selector, additionalInfo: { attribute } };
+ return await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`Getting attribute '${attribute}' from: ${selector}`);
+ return await this.page.locator(selector).getAttribute(attribute);
+ }, context);
+ }
+ /**
+ * Check if element is visible
+ */
+ async isVisible(selector, timeout) {
+ try {
+ await this.page.locator(selector).waitFor({
+ state: 'visible',
+ timeout: timeout || 5000
+ });
+ return true;
+ }
+ catch {
+ return false;
+ }
+ }
+ /**
+ * Check if element is enabled
+ */
+ async isEnabled(selector) {
+ return await this.page.locator(selector).isEnabled();
+ }
+ /**
+ * Check if checkbox/radio is checked
+ */
+ async isChecked(selector) {
+ return await this.page.locator(selector).isChecked();
+ }
+ /**
+ * Hover over an element
+ */
+ async hover(selector) {
+ const context = { action: 'hover', selector };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`Hovering over: ${selector}`);
+ await this.page.locator(selector).hover();
+ logger_1.logger.debug(`Successfully hovered: ${selector}`);
+ }, context);
+ }
+ /**
+ * Double click an element
+ */
+ async doubleClick(selector) {
+ const context = { action: 'doubleClick', selector };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`Double clicking: ${selector}`);
+ await this.page.locator(selector).dblclick();
+ logger_1.logger.debug(`Successfully double clicked: ${selector}`);
+ }, context);
+ }
+ /**
+ * Press a keyboard key
+ */
+ async press(selector, key) {
+ const context = { action: 'press', selector, additionalInfo: { key } };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`Pressing key '${key}' on: ${selector}`);
+ await this.page.locator(selector).press(key);
+ logger_1.logger.debug(`Successfully pressed key: ${key}`);
+ }, context);
+ }
+ /**
+ * Scroll to an element
+ */
+ async scrollToElement(selector) {
+ const context = { action: 'scrollToElement', selector };
+ await this.errorHandler.wrapAction(async () => {
+ logger_1.logger.debug(`Scrolling to element: ${selector}`);
+ await this.page.locator(selector).scrollIntoViewIfNeeded();
+ logger_1.logger.debug(`Successfully scrolled to: ${selector}`);
+ }, context);
+ }
+ /**
+ * Take a screenshot
+ */
+ async screenshot(options) {
+ logger_1.logger.debug('Taking screenshot');
+ return await this.page.screenshot({
+ path: options?.path,
+ fullPage: options?.fullPage || false
+ });
+ }
+ /**
+ * Get current URL
+ */
+ getCurrentUrl() {
+ return this.page.url();
+ }
+ /**
+ * Get page title
+ */
+ async getTitle() {
+ return await this.page.title();
+ }
+ /**
+ * Reload the page
+ */
+ async reload() {
+ logger_1.logger.debug('Reloading page');
+ await this.page.reload();
+ logger_1.logger.debug('Page reloaded');
+ }
+ /**
+ * Go back in browser history
+ */
+ async goBack() {
+ logger_1.logger.debug('Going back in history');
+ await this.page.goBack();
+ logger_1.logger.debug('Navigated back');
+ }
+ /**
+ * Go forward in browser history
+ */
+ async goForward() {
+ logger_1.logger.debug('Going forward in history');
+ await this.page.goForward();
+ logger_1.logger.debug('Navigated forward');
+ }
+ /**
+ * Execute JavaScript in the browser
+ */
+ async evaluate(script, ...args) {
+ logger_1.logger.debug('Executing script in browser');
+ return await this.page.evaluate(script, ...args);
+ }
+ /**
+ * Get locator for advanced operations
+ */
+ getLocator(selector) {
+ return this.page.locator(selector);
+ }
+}
+exports.WebActions = WebActions;
diff --git a/dist/src/web/browserActions.d.ts b/dist/src/web/browserActions.d.ts
new file mode 100644
index 0000000..e3d81b7
--- /dev/null
+++ b/dist/src/web/browserActions.d.ts
@@ -0,0 +1,18 @@
+import { Browser, BrowserContext, Page } from '@playwright/test';
+export declare class BrowserActions {
+ private browser;
+ private contexts;
+ private logger;
+ constructor(browser: Browser);
+ closeAllBrowsers(): Promise;
+ closeCurrentContext(): Promise;
+ closeAllContextsExceptFirst(): Promise;
+ closeAllTabsExceptFirst(context: BrowserContext): Promise;
+ createNewTab(): Promise;
+ switchToTab(index: number): Promise;
+ getCurrentContext(): Promise;
+ getAllTabs(): Promise;
+ getTabCount(): Promise;
+ createNewIncognitoWindow(): Promise;
+ closeInactiveContexts(): Promise;
+}
diff --git a/dist/src/web/browserActions.js b/dist/src/web/browserActions.js
new file mode 100644
index 0000000..72041fb
--- /dev/null
+++ b/dist/src/web/browserActions.js
@@ -0,0 +1,149 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.BrowserActions = void 0;
+const logger_1 = require("../reporting/logger");
+class BrowserActions {
+ constructor(browser) {
+ this.browser = browser;
+ this.contexts = [];
+ this.logger = new logger_1.Logger('BrowserActions');
+ }
+ async closeAllBrowsers() {
+ try {
+ this.logger.debug('Closing all browser instances');
+ await this.browser.close();
+ }
+ catch (error) {
+ this.logger.error('Failed to close all browsers');
+ throw error;
+ }
+ }
+ async closeCurrentContext() {
+ try {
+ this.logger.debug('Closing current browser context');
+ const context = await this.getCurrentContext();
+ if (context) {
+ await context.close();
+ }
+ }
+ catch (error) {
+ this.logger.error('Failed to close current context');
+ throw error;
+ }
+ }
+ async closeAllContextsExceptFirst() {
+ try {
+ this.logger.debug('Closing all contexts except first');
+ const contexts = this.browser.contexts();
+ for (let i = 1; i < contexts.length; i++) {
+ await contexts[i].close();
+ }
+ }
+ catch (error) {
+ this.logger.error('Failed to close contexts');
+ throw error;
+ }
+ }
+ async closeAllTabsExceptFirst(context) {
+ try {
+ this.logger.debug('Closing all tabs except first');
+ const pages = context.pages();
+ for (let i = 1; i < pages.length; i++) {
+ await pages[i].close();
+ }
+ }
+ catch (error) {
+ this.logger.error('Failed to close tabs');
+ throw error;
+ }
+ }
+ async createNewTab() {
+ try {
+ this.logger.debug('Creating new tab');
+ const context = await this.getCurrentContext();
+ if (!context) {
+ throw new Error('No browser context available');
+ }
+ return await context.newPage();
+ }
+ catch (error) {
+ this.logger.error('Failed to create new tab');
+ throw error;
+ }
+ }
+ async switchToTab(index) {
+ try {
+ this.logger.debug(`Switching to tab at index: ${index}`);
+ const context = await this.getCurrentContext();
+ if (!context) {
+ throw new Error('No browser context available');
+ }
+ const pages = context.pages();
+ if (index >= pages.length) {
+ throw new Error(`Tab index ${index} out of bounds`);
+ }
+ return pages[index];
+ }
+ catch (error) {
+ this.logger.error(`Failed to switch to tab ${index}`);
+ throw error;
+ }
+ }
+ async getCurrentContext() {
+ try {
+ const contexts = this.browser.contexts();
+ return contexts[contexts.length - 1] || null;
+ }
+ catch (error) {
+ this.logger.error('Failed to get current context');
+ throw error;
+ }
+ }
+ async getAllTabs() {
+ try {
+ const context = await this.getCurrentContext();
+ return context ? context.pages() : [];
+ }
+ catch (error) {
+ this.logger.error('Failed to get all tabs');
+ throw error;
+ }
+ }
+ async getTabCount() {
+ try {
+ const tabs = await this.getAllTabs();
+ return tabs.length;
+ }
+ catch (error) {
+ this.logger.error('Failed to get tab count');
+ throw error;
+ }
+ }
+ async createNewIncognitoWindow() {
+ try {
+ this.logger.debug('Creating new incognito window');
+ return await this.browser.newContext();
+ }
+ catch (error) {
+ this.logger.error('Failed to create incognito window');
+ throw error;
+ }
+ }
+ async closeInactiveContexts() {
+ try {
+ this.logger.debug('Closing inactive contexts');
+ const contexts = this.browser.contexts();
+ const currentContext = await this.getCurrentContext();
+ for (const context of contexts) {
+ if (context !== currentContext) {
+ await context.close();
+ }
+ }
+ }
+ catch (error) {
+ this.logger.error('Failed to close inactive contexts');
+ throw error;
+ }
+ }
+}
+exports.BrowserActions = BrowserActions;
diff --git a/dist/src/web/checks.d.ts b/dist/src/web/checks.d.ts
new file mode 100644
index 0000000..59c52f7
--- /dev/null
+++ b/dist/src/web/checks.d.ts
@@ -0,0 +1,15 @@
+import { Page } from '@playwright/test';
+export declare class Validations {
+ private page;
+ private logger;
+ private defaultTimeout;
+ constructor(page: Page);
+ isExisting(selector: string, timeout?: number): Promise;
+ isVisible(selector: string, timeout?: number): Promise;
+ isClickable(selector: string, timeout?: number): Promise;
+ hasText(selector: string, text: string, timeout?: number): Promise;
+ hasValue(selector: string, value: string, timeout?: number): Promise;
+ hasAttribute(selector: string, attribute: string, value?: string): Promise;
+ isEnabled(selector: string): Promise;
+ isChecked(selector: string): Promise;
+}
diff --git a/dist/src/web/checks.js b/dist/src/web/checks.js
new file mode 100644
index 0000000..e11ecc1
--- /dev/null
+++ b/dist/src/web/checks.js
@@ -0,0 +1,104 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Validations = void 0;
+const logger_1 = require("../reporting/logger");
+class Validations {
+ constructor(page) {
+ this.defaultTimeout = 30000;
+ this.page = page;
+ this.logger = new logger_1.Logger('Validations');
+ }
+ async isExisting(selector, timeout) {
+ try {
+ await this.page.waitForSelector(selector, {
+ timeout: timeout || this.defaultTimeout,
+ state: 'attached'
+ });
+ return true;
+ }
+ catch {
+ return false;
+ }
+ }
+ async isVisible(selector, timeout) {
+ try {
+ await this.page.waitForSelector(selector, {
+ timeout: timeout || this.defaultTimeout,
+ state: 'visible'
+ });
+ return true;
+ }
+ catch {
+ return false;
+ }
+ }
+ async isClickable(selector, timeout) {
+ try {
+ const element = await this.page.waitForSelector(selector, {
+ timeout: timeout || this.defaultTimeout,
+ state: 'visible'
+ });
+ if (!element)
+ return false;
+ const isDisabled = await element.getAttribute('disabled');
+ const isVisible = await element.isVisible();
+ return isVisible && !isDisabled;
+ }
+ catch {
+ return false;
+ }
+ }
+ async hasText(selector, text, timeout) {
+ try {
+ await this.page.waitForSelector(selector, {
+ timeout: timeout || this.defaultTimeout
+ });
+ const elementText = await this.page.textContent(selector);
+ return elementText?.includes(text) ?? false;
+ }
+ catch {
+ return false;
+ }
+ }
+ async hasValue(selector, value, timeout) {
+ try {
+ await this.page.waitForSelector(selector, {
+ timeout: timeout || this.defaultTimeout
+ });
+ const inputValue = await this.page.inputValue(selector);
+ return inputValue === value;
+ }
+ catch {
+ return false;
+ }
+ }
+ async hasAttribute(selector, attribute, value) {
+ try {
+ const attributeValue = await this.page.getAttribute(selector, attribute);
+ if (!value)
+ return attributeValue !== null;
+ return attributeValue === value;
+ }
+ catch {
+ return false;
+ }
+ }
+ async isEnabled(selector) {
+ try {
+ const isDisabled = await this.page.$eval(selector, (el) => el.hasAttribute('disabled'));
+ return !isDisabled;
+ }
+ catch {
+ return false;
+ }
+ }
+ async isChecked(selector) {
+ try {
+ return await this.page.isChecked(selector);
+ }
+ catch {
+ return false;
+ }
+ }
+}
+exports.Validations = Validations;
diff --git a/dist/tests/pages/cart.page.d.ts b/dist/tests/pages/cart.page.d.ts
new file mode 100644
index 0000000..2db665f
--- /dev/null
+++ b/dist/tests/pages/cart.page.d.ts
@@ -0,0 +1,44 @@
+import { Page } from '@playwright/test';
+import { WebActions } from '../../src/web/actions';
+type CartItem = {
+ name: string;
+ price: string;
+ quantity: number;
+};
+/**
+ * Cart page actions
+ */
+export declare class CartPage {
+ private page;
+ private actions;
+ constructor(page: Page, actions: WebActions);
+ /**
+ * Get all items currently in the cart
+ */
+ getCartItems(): Promise;
+ /**
+ * Click checkout button to proceed to checkout
+ */
+ proceedToCheckout(): Promise;
+ /**
+ * Fill checkout information form
+ */
+ fillCheckoutInfo(firstName: string, lastName: string, postalCode: string): Promise;
+ /**
+ * Complete the order by clicking finish button
+ */
+ completeOrder(): Promise;
+ /**
+ * Get the confirmation message after order completion
+ */
+ getConfirmationMessage(): Promise;
+ /**
+ * Remove a specific item from cart by product name
+ */
+ removeItem(productName: string): Promise;
+ /**
+ * Calculate total price of items in cart
+ */
+ getCartTotal(): Promise;
+}
+export {};
diff --git a/dist/tests/pages/cart.page.js b/dist/tests/pages/cart.page.js
new file mode 100644
index 0000000..68b17ca
--- /dev/null
+++ b/dist/tests/pages/cart.page.js
@@ -0,0 +1,81 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.CartPage = void 0;
+// Locators
+const Locators = {
+ CART_ITEM: '.cart_item',
+ CHECKOUT_BUTTON: '#checkout',
+ CONTINUE_BUTTON: '#continue',
+ FINISH_BUTTON: '#finish',
+ FIRST_NAME_INPUT: '#first-name',
+ LAST_NAME_INPUT: '#last-name',
+ POSTAL_CODE_INPUT: '#postal-code',
+ CONFIRMATION_HEADER: '.complete-header',
+ ITEM_NAME: '.inventory_item_name',
+ ITEM_PRICE: '.inventory_item_price'
+};
+/**
+ * Cart page actions
+ */
+class CartPage {
+ constructor(page, actions) {
+ this.page = page;
+ this.actions = actions;
+ }
+ /**
+ * Get all items currently in the cart
+ */
+ async getCartItems() {
+ const items = await this.page.$$eval(Locators.CART_ITEM, (elements) => {
+ return elements.map((el) => {
+ const name = el.querySelector('.inventory_item_name')?.textContent || '';
+ const price = el.querySelector('.inventory_item_price')?.textContent || '';
+ return { name, price, quantity: 1 }; // Default quantity is 1 as SauceDemo doesn't support quantity changes
+ });
+ });
+ return items;
+ }
+ /**
+ * Click checkout button to proceed to checkout
+ */
+ async proceedToCheckout() {
+ await this.actions.click(Locators.CHECKOUT_BUTTON);
+ }
+ /**
+ * Fill checkout information form
+ */
+ async fillCheckoutInfo(firstName, lastName, postalCode) {
+ await this.actions.fill(Locators.FIRST_NAME_INPUT, firstName);
+ await this.actions.fill(Locators.LAST_NAME_INPUT, lastName);
+ await this.actions.fill(Locators.POSTAL_CODE_INPUT, postalCode);
+ await this.actions.click(Locators.CONTINUE_BUTTON);
+ }
+ /**
+ * Complete the order by clicking finish button
+ */
+ async completeOrder() {
+ await this.actions.click(Locators.FINISH_BUTTON);
+ }
+ /**
+ * Get the confirmation message after order completion
+ */
+ async getConfirmationMessage() {
+ const messageElement = await this.page.$(Locators.CONFIRMATION_HEADER);
+ return messageElement ? await messageElement.textContent() : null;
+ }
+ /**
+ * Remove a specific item from cart by product name
+ */
+ async removeItem(productName) {
+ const selector = `//div[text()="${productName}"]/ancestor::div[@class="cart_item"]//button[contains(@id, "remove")]`;
+ await this.actions.click(selector);
+ }
+ /**
+ * Calculate total price of items in cart
+ */
+ async getCartTotal() {
+ const items = await this.getCartItems();
+ return items.reduce((total, item) => total + parseFloat(item.price.replace('$', '')), 0);
+ }
+}
+exports.CartPage = CartPage;
diff --git a/dist/tests/pages/login.page.d.ts b/dist/tests/pages/login.page.d.ts
new file mode 100644
index 0000000..c83e41e
--- /dev/null
+++ b/dist/tests/pages/login.page.d.ts
@@ -0,0 +1,30 @@
+import { Page } from '@playwright/test';
+import { WebActions } from '../../src/web/actions';
+/**
+ * Login page actions
+ */
+export declare class LoginPage {
+ private page;
+ private actions;
+ constructor(page: Page, actions: WebActions);
+ /**
+ * Navigate to the login page
+ */
+ navigateToLogin(): Promise;
+ /**
+ * Login with the given credentials
+ * @param username Username (defaults to standard_user)
+ * @param password Password (defaults to secret_sauce)
+ */
+ login(username?: string, password?: string): Promise;
+ /**
+ * Get the error message if login fails
+ * @returns The error message text or null if no error
+ */
+ getErrorMessage(): Promise;
+ /**
+ * Check if user is logged in by looking for the inventory list
+ * @returns true if logged in, false otherwise
+ */
+ isLoggedIn(): Promise;
+}
diff --git a/dist/tests/pages/login.page.js b/dist/tests/pages/login.page.js
new file mode 100644
index 0000000..45ed6fd
--- /dev/null
+++ b/dist/tests/pages/login.page.js
@@ -0,0 +1,59 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.LoginPage = void 0;
+// Locators - Makes it easier to maintain selectors in one place
+const Locators = {
+ USERNAME_INPUT: '#user-name',
+ PASSWORD_INPUT: '#password',
+ LOGIN_BUTTON: '#login-button',
+ ERROR_MESSAGE: '.error-message-container',
+ INVENTORY_LIST: '.inventory_list'
+};
+const URL = 'https://www.saucedemo.com/';
+/**
+ * Login page actions
+ */
+class LoginPage {
+ constructor(page, actions) {
+ this.page = page;
+ this.actions = actions;
+ }
+ /**
+ * Navigate to the login page
+ */
+ async navigateToLogin() {
+ await this.actions.navigateTo(URL);
+ }
+ /**
+ * Login with the given credentials
+ * @param username Username (defaults to standard_user)
+ * @param password Password (defaults to secret_sauce)
+ */
+ async login(username = 'standard_user', password = 'secret_sauce') {
+ await this.actions.fill(Locators.USERNAME_INPUT, username);
+ await this.actions.fill(Locators.PASSWORD_INPUT, password);
+ await this.actions.click(Locators.LOGIN_BUTTON);
+ }
+ /**
+ * Get the error message if login fails
+ * @returns The error message text or null if no error
+ */
+ async getErrorMessage() {
+ const errorElement = await this.page.$(Locators.ERROR_MESSAGE);
+ return errorElement ? await errorElement.textContent() : null;
+ }
+ /**
+ * Check if user is logged in by looking for the inventory list
+ * @returns true if logged in, false otherwise
+ */
+ async isLoggedIn() {
+ try {
+ await this.page.waitForSelector(Locators.INVENTORY_LIST, { timeout: 5000 });
+ return true;
+ }
+ catch {
+ return false;
+ }
+ }
+}
+exports.LoginPage = LoginPage;
diff --git a/dist/tests/pages/products.page.d.ts b/dist/tests/pages/products.page.d.ts
new file mode 100644
index 0000000..42123d4
--- /dev/null
+++ b/dist/tests/pages/products.page.d.ts
@@ -0,0 +1,48 @@
+import { Page } from '@playwright/test';
+import { WebActions } from '../../src/web/actions';
+/**
+ * Products page actions
+ */
+export declare class ProductsPage {
+ private page;
+ private actions;
+ constructor(page: Page, actions: WebActions);
+ /**
+ * Add a product to cart by its name
+ * @param productName The name of the product to add
+ */
+ addToCart(productName: string): Promise;
+ /**
+ * Remove a product from cart by its name
+ * @param productName The name of the product to remove
+ */
+ removeFromCart(productName: string): Promise;
+ /**
+ * Get the number of items in the cart
+ * @returns The number of items in cart, 0 if cart is empty
+ */
+ getCartItemsCount(): Promise;
+ /**
+ * Navigate to the cart page
+ */
+ goToCart(): Promise;
+ /**
+ * Get the price of a specific product
+ * @param productName The name of the product
+ * @returns The price as a string or null if not found
+ */
+ getProductPrice(productName: string): Promise;
+ /**
+ * Sort products by the given option
+ * @param option Sort option: az (A to Z), za (Z to A), lohi (Low to High), hilo (High to Low)
+ */
+ sortProducts(option: 'az' | 'za' | 'lohi' | 'hilo'): Promise;
+ /**
+ * Get all products from the inventory
+ * @returns Array of products with their names and prices
+ */
+ getAllProducts(): Promise<{
+ name: string;
+ price: string;
+ }[]>;
+}
diff --git a/dist/tests/pages/products.page.js b/dist/tests/pages/products.page.js
new file mode 100644
index 0000000..bc3edfa
--- /dev/null
+++ b/dist/tests/pages/products.page.js
@@ -0,0 +1,86 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ProductsPage = void 0;
+// Locators
+const Locators = {
+ INVENTORY_ITEM: '.inventory_item',
+ CART_BADGE: '.shopping_cart_badge',
+ CART_LINK: '.shopping_cart_link',
+ SORT_DROPDOWN: '.product_sort_container',
+ ITEM_PRICE: '.inventory_item_price',
+ ITEM_NAME: '.inventory_item_name'
+};
+/**
+ * Products page actions
+ */
+class ProductsPage {
+ constructor(page, actions) {
+ this.page = page;
+ this.actions = actions;
+ }
+ /**
+ * Add a product to cart by its name
+ * @param productName The name of the product to add
+ */
+ async addToCart(productName) {
+ const selector = `//div[text()="${productName}"]/ancestor::div[@class="inventory_item"]//button[contains(@id, "add-to-cart")]`;
+ await this.actions.click(selector);
+ }
+ /**
+ * Remove a product from cart by its name
+ * @param productName The name of the product to remove
+ */
+ async removeFromCart(productName) {
+ const selector = `//div[text()="${productName}"]/ancestor::div[@class="inventory_item"]//button[contains(@id, "remove")]`;
+ await this.actions.click(selector);
+ }
+ /**
+ * Get the number of items in the cart
+ * @returns The number of items in cart, 0 if cart is empty
+ */
+ async getCartItemsCount() {
+ const badge = await this.page.$(Locators.CART_BADGE);
+ const text = badge ? await badge.textContent() : null;
+ return text ? parseInt(text) : 0;
+ }
+ /**
+ * Navigate to the cart page
+ */
+ async goToCart() {
+ await this.actions.click(Locators.CART_LINK);
+ }
+ /**
+ * Get the price of a specific product
+ * @param productName The name of the product
+ * @returns The price as a string or null if not found
+ */
+ async getProductPrice(productName) {
+ const selector = `//div[text()="${productName}"]/ancestor::div[@class="inventory_item"]//div[@class="inventory_item_price"]`;
+ const element = await this.page.$(selector);
+ return element ? element.textContent() : null;
+ }
+ /**
+ * Sort products by the given option
+ * @param option Sort option: az (A to Z), za (Z to A), lohi (Low to High), hilo (High to Low)
+ */
+ async sortProducts(option) {
+ await this.page.selectOption(Locators.SORT_DROPDOWN, option);
+ }
+ /**
+ * Get all products from the inventory
+ * @returns Array of products with their names and prices
+ */
+ async getAllProducts() {
+ const products = await this.page.$$(Locators.INVENTORY_ITEM);
+ const result = [];
+ for (const product of products) {
+ const nameElement = await product.$(Locators.ITEM_NAME);
+ const priceElement = await product.$(Locators.ITEM_PRICE);
+ const name = nameElement ? (await nameElement.textContent()) || '' : '';
+ const price = priceElement ? (await priceElement.textContent()) || '' : '';
+ result.push({ name, price });
+ }
+ return result;
+ }
+}
+exports.ProductsPage = ProductsPage;
diff --git a/dist/tests/steps/shopping.steps.d.ts b/dist/tests/steps/shopping.steps.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/tests/steps/shopping.steps.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/tests/steps/shopping.steps.js b/dist/tests/steps/shopping.steps.js
new file mode 100644
index 0000000..3a47525
--- /dev/null
+++ b/dist/tests/steps/shopping.steps.js
@@ -0,0 +1,71 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+const cucumber_1 = require("@cucumber/cucumber");
+const test_1 = require("@playwright/test");
+(0, cucumber_1.Given)('I am on the Sauce Demo login page', async function () {
+ this.scenarioLogger.step('Navigating to Sauce Demo login page');
+ await this.loginPage.navigateToLogin();
+ this.scenarioLogger.info('Successfully loaded login page');
+});
+(0, cucumber_1.When)('I login with standard user credentials', async function () {
+ this.scenarioLogger.step('Logging in with standard user credentials');
+ await this.loginPage.login('standard_user', 'secret_sauce');
+ this.scenarioLogger.info('Login credentials submitted');
+});
+(0, cucumber_1.Then)('I should see the products page', async function () {
+ this.scenarioLogger.step('Verifying products page is displayed');
+ const isOnProductsPage = await this.loginPage.isLoggedIn();
+ (0, test_1.expect)(isOnProductsPage).toBe(true);
+ this.scenarioLogger.info('Products page verified successfully');
+});
+(0, cucumber_1.When)('I add {string} to cart', async function (productName) {
+ this.scenarioLogger.step(`Adding product "${productName}" to cart`);
+ await this.productsPage.addToCart(productName);
+ this.scenarioLogger.info(`Product "${productName}" added to cart`);
+});
+(0, cucumber_1.When)('I click on cart icon', async function () {
+ this.scenarioLogger.step('Clicking on cart icon');
+ await this.productsPage.goToCart();
+ this.scenarioLogger.info('Navigated to cart page');
+});
+(0, cucumber_1.Then)('I should see {string} in my cart', async function (productName) {
+ this.scenarioLogger.step(`Verifying "${productName}" is in the cart`);
+ const items = await this.cartPage.getCartItems();
+ const item = items.find(item => item.name === productName);
+ (0, test_1.expect)(item).toBeDefined();
+ this.scenarioLogger.info(`Verified "${productName}" is present in cart`);
+});
+(0, cucumber_1.When)('I navigate to cart', async function () {
+ this.scenarioLogger.step('Navigating to cart');
+ await this.productsPage.goToCart();
+ this.scenarioLogger.info('Opened cart page');
+});
+(0, cucumber_1.When)('I proceed to checkout', async function () {
+ this.scenarioLogger.step('Proceeding to checkout');
+ await this.cartPage.proceedToCheckout();
+ this.scenarioLogger.info('Checkout page loaded');
+});
+(0, cucumber_1.When)('I fill checkout information with following details:', async function (dataTable) {
+ const [_, data] = dataTable.rawTable;
+ const [firstName, lastName, postalCode] = data;
+ this.scenarioLogger.step(`Filling checkout information: ${firstName} ${lastName}, ${postalCode}`);
+ await this.cartPage.fillCheckoutInfo(firstName, lastName, postalCode);
+ this.scenarioLogger.info('Checkout information submitted successfully');
+});
+(0, cucumber_1.When)('I complete the purchase', async function () {
+ this.scenarioLogger.step('Completing the purchase');
+ await this.cartPage.completeOrder();
+ this.scenarioLogger.info('Purchase completed');
+});
+(0, cucumber_1.Then)('I should see the confirmation message', async function () {
+ this.scenarioLogger.step('Verifying order confirmation message');
+ const message = await this.cartPage.getConfirmationMessage();
+ (0, test_1.expect)(message).toContain('Thank you for your order!');
+ this.scenarioLogger.info(`Confirmation message verified: "${message}"`);
+});
+(0, cucumber_1.Then)('I should see {int} items in the cart', async function (count) {
+ this.scenarioLogger.step(`Verifying cart contains ${count} item(s)`);
+ const actualCount = await this.productsPage.getCartItemsCount();
+ (0, test_1.expect)(actualCount).toBe(count);
+ this.scenarioLogger.info(`Cart count verified: ${actualCount} item(s)`);
+});
diff --git a/dist/tests/support/hooks.d.ts b/dist/tests/support/hooks.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/tests/support/hooks.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/tests/support/hooks.js b/dist/tests/support/hooks.js
new file mode 100644
index 0000000..7f16930
--- /dev/null
+++ b/dist/tests/support/hooks.js
@@ -0,0 +1,206 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const cucumber_1 = require("@cucumber/cucumber");
+const test_1 = require("@playwright/test");
+const test_config_1 = require("../../config/test.config");
+const logger_1 = require("../../src/utils/logger");
+const path_1 = __importDefault(require("path"));
+const fs_1 = __importDefault(require("fs"));
+let browser;
+let scenarioLogger;
+// Set default timeout from config
+(0, cucumber_1.setDefaultTimeout)(test_config_1.config.timeout);
+/**
+ * Launch browser before all tests
+ */
+(0, cucumber_1.BeforeAll)(async function () {
+ logger_1.logger.info('='.repeat(60));
+ logger_1.logger.info('Starting test suite execution');
+ logger_1.logger.info('='.repeat(60));
+ try {
+ const launchOptions = (0, test_config_1.getBrowserLaunchOptions)();
+ // Select browser based on config
+ switch (test_config_1.config.browser) {
+ case 'firefox':
+ browser = await test_1.firefox.launch(launchOptions);
+ logger_1.logger.info('Firefox browser launched');
+ break;
+ case 'webkit':
+ browser = await test_1.webkit.launch(launchOptions);
+ logger_1.logger.info('WebKit browser launched');
+ break;
+ default:
+ browser = await test_1.chromium.launch(launchOptions);
+ logger_1.logger.info('Chromium browser launched');
+ }
+ }
+ catch (error) {
+ logger_1.logger.error('Failed to launch browser', { error });
+ throw error;
+ }
+});
+/**
+ * Setup before each scenario
+ */
+(0, cucumber_1.Before)(async function ({ pickle, gherkinDocument }) {
+ const scenarioName = pickle.name;
+ scenarioLogger = new logger_1.ScenarioLogger(scenarioName);
+ scenarioLogger.info('Starting scenario');
+ try {
+ // Create browser context with configuration
+ const contextOptions = (0, test_config_1.getBrowserContextOptions)();
+ const context = await browser.newContext(contextOptions);
+ // Start tracing if enabled
+ if (test_config_1.config.trace.enabled) {
+ await context.tracing.start({
+ screenshots: true,
+ snapshots: true,
+ sources: true
+ });
+ scenarioLogger.debug('Tracing started');
+ }
+ // Create new page
+ const page = await context.newPage();
+ // Add console message listener
+ page.on('console', msg => {
+ scenarioLogger.debug(`Browser console [${msg.type()}]: ${msg.text()}`);
+ });
+ // Add error listener
+ page.on('pageerror', error => {
+ scenarioLogger.error('Page error occurred', { error: error.message });
+ });
+ // Add request failure listener (filter out non-critical failures)
+ page.on('requestfailed', request => {
+ const url = request.url();
+ const failure = request.failure()?.errorText;
+ // Ignore known third-party failures that don't affect tests
+ const ignoredPatterns = [
+ 'backtrace.io',
+ 'fonts.gstatic.com',
+ 'fonts.googleapis.com',
+ 'analytics',
+ 'google-analytics',
+ 'gtag',
+ 'doubleclick'
+ ];
+ const shouldIgnore = ignoredPatterns.some(pattern => url.includes(pattern));
+ if (!shouldIgnore) {
+ scenarioLogger.warn('ā ļø Request failed', {
+ url,
+ failure
+ });
+ }
+ else {
+ // Log at debug level for ignored requests
+ scenarioLogger.debug('Third-party request failed (ignored)', {
+ url,
+ failure
+ });
+ }
+ });
+ // Initialize World with page and logger
+ await this.initialize(page, scenarioLogger);
+ scenarioLogger.info('Browser context and page initialized');
+ // Navigate to base URL if feature has @ui tag
+ const tags = pickle.tags.map(t => t.name);
+ if (tags.includes('@ui')) {
+ await page.goto(test_config_1.config.baseURL, { waitUntil: 'domcontentloaded' });
+ scenarioLogger.info('Navigated to base URL', { url: test_config_1.config.baseURL });
+ }
+ }
+ catch (error) {
+ scenarioLogger.error('Failed to setup scenario', { error });
+ throw error;
+ }
+});
+/**
+ * Cleanup after each scenario
+ */
+(0, cucumber_1.After)(async function ({ pickle, result }) {
+ const scenarioName = pickle.name;
+ const status = result?.status;
+ try {
+ // Handle failure - capture artifacts
+ if (status === cucumber_1.Status.FAILED) {
+ scenarioLogger.error('Scenario failed');
+ // Capture screenshot
+ if (test_config_1.config.screenshot.enabled) {
+ const screenshotPath = path_1.default.join(test_config_1.config.screenshot.dir, `${sanitizeFileName(scenarioName)}-${Date.now()}.png`);
+ // Ensure directory exists
+ fs_1.default.mkdirSync(test_config_1.config.screenshot.dir, { recursive: true });
+ const screenshot = await this.page.screenshot({
+ path: screenshotPath,
+ fullPage: true
+ });
+ // Attach to Cucumber report
+ this.attach(screenshot, 'image/png');
+ scenarioLogger.info('Screenshot captured', { path: screenshotPath });
+ }
+ // Capture page HTML
+ const html = await this.page.content();
+ this.attach(html, 'text/html');
+ // Capture browser logs
+ const logs = await this.page.evaluate(() => {
+ return window.testLogs || [];
+ });
+ if (logs.length > 0) {
+ this.attach(JSON.stringify(logs, null, 2), 'application/json');
+ }
+ // Stop and save trace
+ if (test_config_1.config.trace.enabled) {
+ const tracePath = path_1.default.join(test_config_1.config.trace.dir, `${sanitizeFileName(scenarioName)}-${Date.now()}.zip`);
+ fs_1.default.mkdirSync(test_config_1.config.trace.dir, { recursive: true });
+ await this.page.context().tracing.stop({ path: tracePath });
+ scenarioLogger.info('Trace saved', { path: tracePath });
+ }
+ }
+ else if (status === cucumber_1.Status.PASSED) {
+ scenarioLogger.info('Scenario passed');
+ // Stop trace without saving if passed (unless mode is 'on')
+ if (test_config_1.config.trace.enabled && test_config_1.config.trace.mode !== 'on') {
+ await this.page.context().tracing.stop();
+ }
+ else if (test_config_1.config.trace.enabled && test_config_1.config.trace.mode === 'on') {
+ const tracePath = path_1.default.join(test_config_1.config.trace.dir, `${sanitizeFileName(scenarioName)}-${Date.now()}.zip`);
+ fs_1.default.mkdirSync(test_config_1.config.trace.dir, { recursive: true });
+ await this.page.context().tracing.stop({ path: tracePath });
+ }
+ }
+ // Close page and context
+ await this.page.context().close();
+ scenarioLogger.info('Browser context closed');
+ }
+ catch (error) {
+ scenarioLogger.error('Error during cleanup', { error });
+ }
+});
+/**
+ * Close browser after all tests
+ */
+(0, cucumber_1.AfterAll)(async function () {
+ try {
+ if (browser) {
+ await browser.close();
+ logger_1.logger.info('Browser closed');
+ }
+ logger_1.logger.info('='.repeat(60));
+ logger_1.logger.info('Test suite execution completed');
+ logger_1.logger.info('='.repeat(60));
+ }
+ catch (error) {
+ logger_1.logger.error('Error closing browser', { error });
+ }
+});
+/**
+ * Helper function to sanitize file names
+ */
+function sanitizeFileName(name) {
+ return name
+ .replace(/[^a-z0-9]/gi, '_')
+ .replace(/_+/g, '_')
+ .toLowerCase()
+ .substring(0, 50);
+}
diff --git a/dist/tests/support/world.d.ts b/dist/tests/support/world.d.ts
new file mode 100644
index 0000000..a6dd4dc
--- /dev/null
+++ b/dist/tests/support/world.d.ts
@@ -0,0 +1,27 @@
+import { World as CucumberWorld, IWorldOptions } from '@cucumber/cucumber';
+import { Browser, Page } from '@playwright/test';
+import { WebActions } from '../../src/web/actions';
+import { LoginPage } from '../pages/login.page';
+import { ProductsPage } from '../pages/products.page';
+import { CartPage } from '../pages/cart.page';
+import { ScenarioLogger } from '../../src/utils/logger';
+export interface TestWorld extends CucumberWorld {
+ browser?: Browser;
+ page: Page;
+ loginPage: LoginPage;
+ productsPage: ProductsPage;
+ cartPage: CartPage;
+ webActions: WebActions;
+ scenarioLogger: ScenarioLogger;
+}
+export declare class CustomWorld extends CucumberWorld implements TestWorld {
+ browser?: Browser;
+ page: Page;
+ loginPage: LoginPage;
+ productsPage: ProductsPage;
+ cartPage: CartPage;
+ webActions: WebActions;
+ scenarioLogger: ScenarioLogger;
+ constructor(options: IWorldOptions);
+ initialize(page: Page, scenarioLogger: ScenarioLogger): Promise;
+}
diff --git a/dist/tests/support/world.js b/dist/tests/support/world.js
new file mode 100644
index 0000000..a05b1d0
--- /dev/null
+++ b/dist/tests/support/world.js
@@ -0,0 +1,24 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.CustomWorld = void 0;
+const cucumber_1 = require("@cucumber/cucumber");
+const actions_1 = require("../../src/web/actions");
+const login_page_1 = require("../pages/login.page");
+const products_page_1 = require("../pages/products.page");
+const cart_page_1 = require("../pages/cart.page");
+class CustomWorld extends cucumber_1.World {
+ constructor(options) {
+ super(options);
+ }
+ async initialize(page, scenarioLogger) {
+ this.page = page;
+ this.scenarioLogger = scenarioLogger;
+ this.webActions = new actions_1.WebActions(this.page);
+ // Initialize page objects with page and actions
+ this.loginPage = new login_page_1.LoginPage(this.page, this.webActions);
+ this.productsPage = new products_page_1.ProductsPage(this.page, this.webActions);
+ this.cartPage = new cart_page_1.CartPage(this.page, this.webActions);
+ }
+}
+exports.CustomWorld = CustomWorld;
+(0, cucumber_1.setWorldConstructor)(CustomWorld);
diff --git a/package.json b/package.json
index b4a3922..0620f36 100644
--- a/package.json
+++ b/package.json
@@ -1,54 +1,62 @@
{
- "name": "@yourorg/playwright-framework",
+ "name": "playwright-cucumber-framework",
"version": "1.0.0",
- "description": "Comprehensive Playwright testing framework with web, API, BDD, and Mocha support",
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
+ "description": "Production-ready Playwright + Cucumber BDD automation framework",
+ "main": "index.js",
"scripts": {
- "build": "tsc",
- "test:ui": "cross-env node tests/run-cucumber.js",
- "test:ui:parallel": "cross-env PARALLEL=2 node tests/run-cucumber.js",
- "lint": "eslint src tests",
- "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
- "init": "playwright install && npm install"
+ "test": "node tests/run-cucumber.js",
+ "test:dev": "cross-env TEST_ENV=dev node tests/run-cucumber.js",
+ "test:staging": "cross-env TEST_ENV=staging node tests/run-cucumber.js",
+ "test:prod": "cross-env TEST_ENV=prod node tests/run-cucumber.js",
+ "test:parallel": "cross-env PARALLEL_WORKERS=2 node tests/run-cucumber.js",
+ "test:parallel:4": "cross-env PARALLEL_WORKERS=4 node tests/run-cucumber.js",
+ "test:ci": "cross-env TEST_ENV=ci tsc && node tests/run-cucumber.js",
+ "test:smoke": "cross-env cucumber-js --tags @smoke",
+ "test:regression": "cross-env cucumber-js --tags @regression",
+ "test:chromium": "cross-env BROWSER=chromium node tests/run-cucumber.js",
+ "test:firefox": "cross-env BROWSER=firefox node tests/run-cucumber.js",
+ "test:webkit": "cross-env BROWSER=webkit node tests/run-cucumber.js",
+ "test:headed": "cross-env HEADLESS=false node tests/run-cucumber.js",
+ "test:debug": "cross-env HEADLESS=false SLOW_MO=1000 node tests/run-cucumber.js",
+ "compile": "tsc",
+ "clean": "rimraf dist test-results logs",
+ "clean:reports": "rimraf test-results/reports",
+ "lint": "eslint . --ext .ts",
+ "format": "prettier --write \"**/*.{ts,js,json,md}\"",
+ "report": "open test-results/reports/cucumber-report.html",
+ "pretest": "npm run compile",
+ "posttest": "echo 'Test execution completed. Check test-results/ for reports.'"
},
"keywords": [
"playwright",
+ "cucumber",
+ "bdd",
"testing",
"automation",
- "bdd",
- "cucumber",
- "mocha",
"typescript"
],
- "author": "",
+ "author": "Your Name",
"license": "MIT",
"dependencies": {
- "@axe-core/playwright": "^4.7.0",
- "@playwright/test": "^1.39.0",
- "dotenv": "^16.0.3",
- "winston": "^3.10.0",
- "zod": "^3.22.0"
+ "@cucumber/cucumber": "^10.3.1",
+ "@playwright/test": "^1.40.1",
+ "dotenv": "^16.6.1",
+ "winston": "^3.18.3"
},
"devDependencies": {
- "@cucumber/cucumber": "^12.2.0",
- "@types/chai": "^4.3.0",
- "@types/mocha": "^10.0.0",
- "@types/node": "^18.0.0",
- "@typescript-eslint/eslint-plugin": "^5.0.0",
- "@typescript-eslint/parser": "^5.0.0",
- "allure-cucumberjs": "^3.4.1",
- "allure-js-commons": "^3.4.1",
- "chai": "^4.3.0",
- "cross-env": "^10.1.0",
- "eslint": "^8.0.0",
- "mocha": "^10.0.0",
- "prettier": "^2.8.0",
- "rimraf": "^6.0.1",
- "ts-node": "^10.9.0",
- "typescript": "^5.0.0"
+ "@types/node": "^20.10.6",
+ "@typescript-eslint/eslint-plugin": "^6.17.0",
+ "@typescript-eslint/parser": "^6.17.0",
+ "cross-env": "^7.0.3",
+ "cucumber-html-reporter": "^7.1.1",
+ "eslint": "^8.57.1",
+ "prettier": "^3.6.2",
+ "rimraf": "^5.0.10",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.3.3"
},
"engines": {
- "node": ">=16.0.0"
+ "node": ">=18.0.0",
+ "npm": ">=9.0.0"
}
}
diff --git a/src/accessibility/accessibility-helper.ts b/src/accessibility/accessibility-helper.ts
new file mode 100644
index 0000000..58ea7a4
--- /dev/null
+++ b/src/accessibility/accessibility-helper.ts
@@ -0,0 +1,474 @@
+import { Page, Locator } from '@playwright/test';
+import { logger } from '../utils/logger';
+
+export interface AccessibilityViolation {
+ id: string;
+ impact: 'critical' | 'serious' | 'moderate' | 'minor';
+ description: string;
+ help: string;
+ helpUrl: string;
+ nodes: Array<{
+ html: string;
+ target: string[];
+ failureSummary?: string;
+ }>;
+}
+
+export interface AccessibilityReport {
+ url: string;
+ timestamp: string;
+ violations: AccessibilityViolation[];
+ passes: number;
+ incomplete: number;
+ score: number;
+}
+
+export interface AccessibilityOptions {
+ includedImpacts?: Array<'critical' | 'serious' | 'moderate' | 'minor'>;
+ tags?: string[]; // WCAG 2.0, WCAG 2.1, best-practice, etc.
+ rules?: {
+ [key: string]: { enabled: boolean };
+ };
+}
+
+/**
+ * Accessibility testing helper using axe-core
+ */
+export class AccessibilityHelper {
+ private violations: AccessibilityViolation[] = [];
+ private reports: AccessibilityReport[] = [];
+
+ constructor(private page: Page) { }
+
+ /**
+ * Inject axe-core library
+ */
+ private async injectAxe(): Promise {
+ try {
+ await this.page.addScriptTag({
+ url: 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.7.2/axe.min.js'
+ });
+ logger.debug('Axe-core injected successfully');
+ } catch (error) {
+ logger.error('Failed to inject axe-core', { error });
+ throw error;
+ }
+ }
+
+ /**
+ * Run accessibility scan on the page
+ */
+ async scanPage(options?: AccessibilityOptions): Promise {
+ logger.info('Running accessibility scan');
+
+ try {
+ // Inject axe-core
+ await this.injectAxe();
+
+ // Run axe scan
+ const results = await this.page.evaluate((opts: any) => {
+ return (window as any).axe.run(document, opts);
+ }, options || {});
+
+ this.violations = results.violations.map((v: any) => ({
+ id: v.id,
+ impact: v.impact,
+ description: v.description,
+ help: v.help,
+ helpUrl: v.helpUrl,
+ nodes: v.nodes.map((n: any) => ({
+ html: n.html,
+ target: n.target,
+ failureSummary: n.failureSummary
+ }))
+ }));
+
+ logger.info('Accessibility scan completed', {
+ violations: this.violations.length,
+ critical: this.violations.filter(v => v.impact === 'critical').length,
+ serious: this.violations.filter(v => v.impact === 'serious').length
+ });
+
+ return this.violations;
+ } catch (error) {
+ logger.error('Accessibility scan failed', { error });
+ throw error;
+ }
+ }
+
+ /**
+ * Scan specific element
+ */
+ async scanElement(selector: string, options?: AccessibilityOptions): Promise {
+ logger.info('Running accessibility scan on element', { selector });
+
+ try {
+ await this.injectAxe();
+
+ const results = await this.page.evaluate(
+ (args: { sel: string; opts?: AccessibilityOptions }) => {
+ const { sel, opts } = args;
+ const element = document.querySelector(sel);
+ if (!element) {
+ throw new Error(`Element not found: ${sel}`);
+ }
+
+ // @ts-ignore - axe is injected dynamically into the page
+ return window.axe.run(element, opts || {});
+ },
+ { sel: selector, opts: options }
+ );
+
+ const violations = results.violations.map((v: any) => ({
+ id: v.id,
+ impact: v.impact,
+ description: v.description,
+ help: v.help,
+ helpUrl: v.helpUrl,
+ nodes: v.nodes.map((n: any) => ({
+ html: n.html,
+ target: n.target,
+ failureSummary: n.failureSummary
+ }))
+ }));
+
+ logger.info('Element accessibility scan completed', {
+ selector,
+ violations: violations.length
+ });
+
+ return violations;
+ } catch (error) {
+ logger.error('Element accessibility scan failed', { selector, error });
+ throw error;
+ }
+ }
+
+ /**
+ * Check for critical violations only
+ */
+ async checkCriticalIssues(): Promise {
+ logger.info('Checking critical accessibility issues');
+
+ const violations = await this.scanPage({
+ includedImpacts: ['critical']
+ });
+
+ const critical = violations.filter(v => v.impact === 'critical');
+
+ if (critical.length > 0) {
+ logger.error('Critical accessibility violations found', { count: critical.length });
+ } else {
+ logger.info('No critical accessibility violations');
+ }
+
+ return critical;
+ }
+
+ /**
+ * Check WCAG 2.1 Level AA compliance
+ */
+ async checkWCAG_AA(): Promise {
+ logger.info('Checking WCAG 2.1 Level AA compliance');
+
+ const violations = await this.scanPage({
+ tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
+ });
+
+ logger.info('WCAG AA scan completed', { violations: violations.length });
+
+ return violations;
+ }
+
+ /**
+ * Check WCAG 2.1 Level AAA compliance
+ */
+ async checkWCAG_AAA(): Promise {
+ logger.info('Checking WCAG 2.1 Level AAA compliance');
+
+ const violations = await this.scanPage({
+ tags: ['wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa']
+ });
+
+ logger.info('WCAG AAA scan completed', { violations: violations.length });
+
+ return violations;
+ }
+
+ /**
+ * Check keyboard navigation
+ */
+ async checkKeyboardNavigation(): Promise<{
+ tabbableElements: number;
+ focusTrapIssues: boolean;
+ skipLinksPresent: boolean;
+ }> {
+ logger.info('Checking keyboard navigation');
+
+ const result = await this.page.evaluate(() => {
+ const tabbableElements = document.querySelectorAll(
+ 'a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
+ ).length;
+
+ const skipLinks = document.querySelectorAll('a[href^="#"]');
+ const skipLinksPresent = skipLinks.length > 0;
+
+ return {
+ tabbableElements,
+ focusTrapIssues: false, // Simplified
+ skipLinksPresent
+ };
+ });
+
+ logger.info('Keyboard navigation check completed', result);
+
+ return result;
+ }
+
+ /**
+ * Check color contrast
+ */
+ async checkColorContrast(): Promise {
+ logger.info('Checking color contrast');
+
+ const violations = await this.scanPage({
+ tags: ['wcag2aa'],
+ rules: {
+ 'color-contrast': { enabled: true }
+ }
+ });
+
+ const contrastViolations = violations.filter(v => v.id === 'color-contrast');
+
+ logger.info('Color contrast check completed', {
+ violations: contrastViolations.length
+ });
+
+ return contrastViolations;
+ }
+
+ /**
+ * Check form labels
+ */
+ async checkFormLabels(): Promise {
+ logger.info('Checking form labels');
+
+ const violations = await this.scanPage({
+ tags: ['wcag2a'],
+ rules: {
+ 'label': { enabled: true },
+ 'label-title-only': { enabled: true }
+ }
+ });
+
+ const labelViolations = violations.filter(v =>
+ v.id.includes('label') || v.id.includes('form')
+ );
+
+ logger.info('Form labels check completed', {
+ violations: labelViolations.length
+ });
+
+ return labelViolations;
+ }
+
+ /**
+ * Check images for alt text
+ */
+ async checkImageAltText(): Promise {
+ logger.info('Checking image alt text');
+
+ const violations = await this.scanPage({
+ rules: {
+ 'image-alt': { enabled: true },
+ 'image-redundant-alt': { enabled: true }
+ }
+ });
+
+ const imageViolations = violations.filter(v => v.id.includes('image'));
+
+ logger.info('Image alt text check completed', {
+ violations: imageViolations.length
+ });
+
+ return imageViolations;
+ }
+
+ /**
+ * Check heading hierarchy
+ */
+ async checkHeadingHierarchy(): Promise {
+ logger.info('Checking heading hierarchy');
+
+ const violations = await this.scanPage({
+ rules: {
+ 'heading-order': { enabled: true },
+ 'empty-heading': { enabled: true }
+ }
+ });
+
+ const headingViolations = violations.filter(v => v.id.includes('heading'));
+
+ logger.info('Heading hierarchy check completed', {
+ violations: headingViolations.length
+ });
+
+ return headingViolations;
+ }
+
+ /**
+ * Check ARIA attributes
+ */
+ async checkARIA(): Promise {
+ logger.info('Checking ARIA attributes');
+
+ const violations = await this.scanPage({
+ tags: ['wcag2a', 'wcag21a']
+ });
+
+ const ariaViolations = violations.filter(v =>
+ v.id.includes('aria') || v.id.includes('role')
+ );
+
+ logger.info('ARIA check completed', {
+ violations: ariaViolations.length
+ });
+
+ return ariaViolations;
+ }
+
+ /**
+ * Get violations by impact
+ */
+ getViolationsByImpact(impact: 'critical' | 'serious' | 'moderate' | 'minor'): AccessibilityViolation[] {
+ return this.violations.filter(v => v.impact === impact);
+ }
+
+ /**
+ * Get all violations
+ */
+ getAllViolations(): AccessibilityViolation[] {
+ return this.violations;
+ }
+
+ /**
+ * Calculate accessibility score (0-100)
+ */
+ calculateScore(): number {
+ let score = 100;
+
+ const critical = this.getViolationsByImpact('critical').length;
+ const serious = this.getViolationsByImpact('serious').length;
+ const moderate = this.getViolationsByImpact('moderate').length;
+ const minor = this.getViolationsByImpact('minor').length;
+
+ score -= critical * 20;
+ score -= serious * 10;
+ score -= moderate * 5;
+ score -= minor * 2;
+
+ return Math.max(0, score);
+ }
+
+ /**
+ * Generate accessibility report
+ */
+ async generateReport(): Promise {
+ logger.info('Generating accessibility report');
+
+ await this.scanPage();
+
+ const report: AccessibilityReport = {
+ url: this.page.url(),
+ timestamp: new Date().toISOString(),
+ violations: this.violations,
+ passes: 0, // Would need full axe results
+ incomplete: 0,
+ score: this.calculateScore()
+ };
+
+ this.reports.push(report);
+
+ logger.info('Accessibility report generated', {
+ url: report.url,
+ violations: report.violations.length,
+ score: report.score
+ });
+
+ return report;
+ }
+
+ /**
+ * Get summary
+ */
+ getSummary(): {
+ total: number;
+ critical: number;
+ serious: number;
+ moderate: number;
+ minor: number;
+ score: number;
+ } {
+ return {
+ total: this.violations.length,
+ critical: this.getViolationsByImpact('critical').length,
+ serious: this.getViolationsByImpact('serious').length,
+ moderate: this.getViolationsByImpact('moderate').length,
+ minor: this.getViolationsByImpact('minor').length,
+ score: this.calculateScore()
+ };
+ }
+
+ /**
+ * Export violations to JSON
+ */
+ exportViolations(): string {
+ return JSON.stringify(this.violations, null, 2);
+ }
+
+ /**
+ * Clear violations
+ */
+ clearViolations(): void {
+ this.violations = [];
+ logger.debug('Accessibility violations cleared');
+ }
+
+ /**
+ * Assert no critical violations
+ */
+ async assertNoCriticalViolations(): Promise {
+ const critical = await this.checkCriticalIssues();
+
+ if (critical.length > 0) {
+ const message = `Found ${critical.length} critical accessibility violations:\n` +
+ critical.map(v => `- ${v.description} (${v.nodes.length} instances)`).join('\n');
+
+ logger.error('Critical accessibility violations assertion failed');
+ throw new Error(message);
+ }
+
+ logger.info('No critical accessibility violations - assertion passed');
+ }
+
+ /**
+ * Assert WCAG compliance
+ */
+ async assertWCAGCompliance(level: 'A' | 'AA' | 'AAA' = 'AA'): Promise {
+ logger.info(`Asserting WCAG ${level} compliance`);
+
+ const violations = level === 'AAA'
+ ? await this.checkWCAG_AAA()
+ : await this.checkWCAG_AA();
+
+ if (violations.length > 0) {
+ const message = `WCAG ${level} compliance failed with ${violations.length} violations:\n` +
+ violations.slice(0, 5).map(v => `- ${v.description}`).join('\n');
+
+ logger.error(`WCAG ${level} compliance assertion failed`);
+ throw new Error(message);
+ }
+
+ logger.info(`WCAG ${level} compliance assertion passed`);
+ }
+}
\ No newline at end of file
diff --git a/src/api/api-client.ts b/src/api/api-client.ts
new file mode 100644
index 0000000..3bfa64f
--- /dev/null
+++ b/src/api/api-client.ts
@@ -0,0 +1,267 @@
+import { APIRequestContext, APIResponse, request } from '@playwright/test';
+import { logger } from '../utils/logger';
+import { config } from '../../config/test.config';
+
+export interface ApiClientOptions {
+ baseURL?: string;
+ extraHTTPHeaders?: Record;
+ timeout?: number;
+}
+
+export interface RequestOptions {
+ params?: Record;
+ headers?: Record;
+ timeout?: number;
+}
+
+export class ApiClient {
+ private context!: APIRequestContext;
+ private baseURL: string;
+ private defaultHeaders: Record;
+
+ constructor(options?: ApiClientOptions) {
+ this.baseURL = options?.baseURL || config.baseURL;
+ this.defaultHeaders = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ ...options?.extraHTTPHeaders
+ };
+ }
+
+ /**
+ * Initialize the API request context
+ */
+ async init(): Promise {
+ this.context = await request.newContext({
+ baseURL: this.baseURL,
+ extraHTTPHeaders: this.defaultHeaders,
+ ignoreHTTPSErrors: true
+ });
+ logger.info('API Client initialized', { baseURL: this.baseURL });
+ }
+
+ /**
+ * Dispose the API request context
+ */
+ async dispose(): Promise {
+ if (this.context) {
+ await this.context.dispose();
+ logger.info('API Client disposed');
+ }
+ }
+
+ /**
+ * GET request
+ */
+ async get(endpoint: string, options?: RequestOptions): Promise {
+ logger.info(`API GET Request`, { endpoint, params: options?.params });
+ const startTime = Date.now();
+
+ try {
+ const response = await this.context.get(endpoint, {
+ params: options?.params,
+ headers: { ...this.defaultHeaders, ...options?.headers },
+ timeout: options?.timeout
+ });
+
+ const duration = Date.now() - startTime;
+ this.logResponse(response, 'GET', endpoint, duration);
+ return response;
+ } catch (error) {
+ logger.error('API GET Request failed', { endpoint, error });
+ throw error;
+ }
+ }
+
+ /**
+ * POST request
+ */
+ async post(endpoint: string, data?: any, options?: RequestOptions): Promise {
+ logger.info(`API POST Request`, { endpoint, hasData: !!data });
+ const startTime = Date.now();
+
+ try {
+ const response = await this.context.post(endpoint, {
+ data,
+ params: options?.params,
+ headers: { ...this.defaultHeaders, ...options?.headers },
+ timeout: options?.timeout
+ });
+
+ const duration = Date.now() - startTime;
+ this.logResponse(response, 'POST', endpoint, duration);
+ return response;
+ } catch (error) {
+ logger.error('API POST Request failed', { endpoint, error });
+ throw error;
+ }
+ }
+
+ /**
+ * PUT request
+ */
+ async put(endpoint: string, data?: any, options?: RequestOptions): Promise {
+ logger.info(`API PUT Request`, { endpoint, hasData: !!data });
+ const startTime = Date.now();
+
+ try {
+ const response = await this.context.put(endpoint, {
+ data,
+ params: options?.params,
+ headers: { ...this.defaultHeaders, ...options?.headers },
+ timeout: options?.timeout
+ });
+
+ const duration = Date.now() - startTime;
+ this.logResponse(response, 'PUT', endpoint, duration);
+ return response;
+ } catch (error) {
+ logger.error('API PUT Request failed', { endpoint, error });
+ throw error;
+ }
+ }
+
+ /**
+ * PATCH request
+ */
+ async patch(endpoint: string, data?: any, options?: RequestOptions): Promise {
+ logger.info(`API PATCH Request`, { endpoint, hasData: !!data });
+ const startTime = Date.now();
+
+ try {
+ const response = await this.context.patch(endpoint, {
+ data,
+ params: options?.params,
+ headers: { ...this.defaultHeaders, ...options?.headers },
+ timeout: options?.timeout
+ });
+
+ const duration = Date.now() - startTime;
+ this.logResponse(response, 'PATCH', endpoint, duration);
+ return response;
+ } catch (error) {
+ logger.error('API PATCH Request failed', { endpoint, error });
+ throw error;
+ }
+ }
+
+ /**
+ * DELETE request
+ */
+ async delete(endpoint: string, options?: RequestOptions): Promise {
+ logger.info(`API DELETE Request`, { endpoint });
+ const startTime = Date.now();
+
+ try {
+ const response = await this.context.delete(endpoint, {
+ params: options?.params,
+ headers: { ...this.defaultHeaders, ...options?.headers },
+ timeout: options?.timeout
+ });
+
+ const duration = Date.now() - startTime;
+ this.logResponse(response, 'DELETE', endpoint, duration);
+ return response;
+ } catch (error) {
+ logger.error('API DELETE Request failed', { endpoint, error });
+ throw error;
+ }
+ }
+
+ /**
+ * Set authentication token
+ */
+ setAuthToken(token: string): void {
+ this.defaultHeaders['Authorization'] = `Bearer ${token}`;
+ logger.info('Auth token set');
+ }
+
+ /**
+ * Remove authentication token
+ */
+ removeAuthToken(): void {
+ delete this.defaultHeaders['Authorization'];
+ logger.info('Auth token removed');
+ }
+
+ /**
+ * Set custom header
+ */
+ setHeader(key: string, value: string): void {
+ this.defaultHeaders[key] = value;
+ logger.debug('Custom header set', { key });
+ }
+
+ /**
+ * Get response body as JSON
+ */
+ async getJsonBody(response: APIResponse): Promise {
+ try {
+ return await response.json();
+ } catch (error) {
+ logger.error('Failed to parse JSON response', { error });
+ throw error;
+ }
+ }
+
+ /**
+ * Get response body as text
+ */
+ async getTextBody(response: APIResponse): Promise {
+ try {
+ return await response.text();
+ } catch (error) {
+ logger.error('Failed to get text response', { error });
+ throw error;
+ }
+ }
+
+ /**
+ * Assert response status
+ */
+ async assertStatus(response: APIResponse, expectedStatus: number): Promise {
+ const actualStatus = response.status();
+ if (actualStatus !== expectedStatus) {
+ const body = await this.getTextBody(response);
+ logger.error('Status assertion failed', {
+ expected: expectedStatus,
+ actual: actualStatus,
+ body
+ });
+ throw new Error(`Expected status ${expectedStatus}, got ${actualStatus}`);
+ }
+ logger.info('Status assertion passed', { status: expectedStatus });
+ }
+
+ /**
+ * Assert response is OK (200-299)
+ */
+ async assertOk(response: APIResponse): Promise {
+ if (!response.ok()) {
+ const body = await this.getTextBody(response);
+ logger.error('OK assertion failed', {
+ status: response.status(),
+ body
+ });
+ throw new Error(`Response not OK: ${response.status()}`);
+ }
+ logger.info('OK assertion passed', { status: response.status() });
+ }
+
+ /**
+ * Log response details
+ */
+ private logResponse(response: APIResponse, method: string, endpoint: string, duration: number): void {
+ const status = response.status();
+ const statusText = response.statusText();
+ const level = status >= 400 ? 'error' : status >= 300 ? 'warn' : 'info';
+
+ logger[level](`API ${method} Response`, {
+ endpoint,
+ status,
+ statusText,
+ duration: `${duration}ms`,
+ ok: response.ok()
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/mobile/mobile-helper.ts b/src/mobile/mobile-helper.ts
new file mode 100644
index 0000000..e45f727
--- /dev/null
+++ b/src/mobile/mobile-helper.ts
@@ -0,0 +1,409 @@
+import { Browser, BrowserContext, Page, devices } from '@playwright/test';
+import { logger } from '../utils/logger';
+
+export interface MobileDevice {
+ name: string;
+ userAgent: string;
+ viewport: {
+ width: number;
+ height: number;
+ };
+ deviceScaleFactor: number;
+ isMobile: boolean;
+ hasTouch: boolean;
+}
+
+export interface CustomDevice {
+ name: string;
+ viewport: {
+ width: number;
+ height: number;
+ };
+ userAgent?: string;
+ deviceScaleFactor?: number;
+ isMobile?: boolean;
+ hasTouch?: boolean;
+}
+
+/**
+ * Mobile and device emulation helper
+ */
+export class MobileHelper {
+ // Predefined popular devices
+ static readonly DEVICES = {
+ // iPhone
+ iPhone_13: devices['iPhone 13'],
+ iPhone_13_Pro: devices['iPhone 13 Pro'],
+ iPhone_13_Pro_Max: devices['iPhone 13 Pro Max'],
+ iPhone_12: devices['iPhone 12'],
+ iPhone_SE: devices['iPhone SE'],
+
+ // iPad
+ iPad_Pro_11: devices['iPad Pro 11'],
+ iPad_Mini: devices['iPad Mini'],
+ iPad: devices['iPad (gen 7)'],
+
+ // Android Phones
+ Pixel_5: devices['Pixel 5'],
+ Pixel_4: devices['Pixel 4'],
+ Galaxy_S9: devices['Galaxy S9+'],
+ Galaxy_S8: devices['Galaxy S8'],
+
+ // Tablets
+ Galaxy_Tab_S4: devices['Galaxy Tab S4'],
+
+ // Desktop
+ Desktop_1920: {
+ viewport: { width: 1920, height: 1080 },
+ deviceScaleFactor: 1,
+ isMobile: false,
+ hasTouch: false
+ },
+ Desktop_1366: {
+ viewport: { width: 1366, height: 768 },
+ deviceScaleFactor: 1,
+ isMobile: false,
+ hasTouch: false
+ }
+ };
+
+ constructor(private browser: Browser) { }
+
+ /**
+ * Create mobile context with device emulation
+ */
+ async createMobileContext(deviceName: keyof typeof MobileHelper.DEVICES): Promise {
+ logger.info('Creating mobile context', { device: deviceName });
+
+ const device = MobileHelper.DEVICES[deviceName];
+
+ const context = await this.browser.newContext({
+ ...device,
+ permissions: ['geolocation'],
+ locale: 'en-US',
+ timezoneId: 'America/New_York'
+ });
+
+ logger.info('Mobile context created', { device: deviceName });
+
+ return context;
+ }
+
+ /**
+ * Create context with custom device
+ */
+ async createCustomDeviceContext(device: CustomDevice): Promise {
+ logger.info('Creating custom device context', { device: device.name });
+
+ const context = await this.browser.newContext({
+ viewport: device.viewport,
+ userAgent: device.userAgent,
+ deviceScaleFactor: device.deviceScaleFactor || 1,
+ isMobile: device.isMobile || false,
+ hasTouch: device.hasTouch || false
+ });
+
+ logger.info('Custom device context created', { device: device.name });
+
+ return context;
+ }
+
+ /**
+ * Emulate specific device on existing page
+ */
+ async emulateDevice(page: Page, deviceName: keyof typeof MobileHelper.DEVICES): Promise {
+ logger.info('Emulating device', { device: deviceName });
+
+ const device = MobileHelper.DEVICES[deviceName];
+
+ await page.setViewportSize(device.viewport);
+
+ if ('userAgent' in device && device.userAgent) {
+ await page.setExtraHTTPHeaders({
+ 'User-Agent': (device as any).userAgent,
+ });
+ }
+
+
+ logger.info('Device emulation applied', { device: deviceName });
+ }
+
+ /**
+ * Set custom viewport
+ */
+ async setViewport(page: Page, width: number, height: number): Promise {
+ logger.info('Setting viewport', { width, height });
+
+ await page.setViewportSize({ width, height });
+
+ logger.debug('Viewport set', { width, height });
+ }
+
+ /**
+ * Rotate device (portrait/landscape)
+ */
+ async rotate(page: Page): Promise {
+ logger.info('Rotating device');
+
+ const viewport = page.viewportSize();
+ if (!viewport) {
+ throw new Error('No viewport set');
+ }
+
+ await page.setViewportSize({
+ width: viewport.height,
+ height: viewport.width
+ });
+
+ logger.info('Device rotated', {
+ from: `${viewport.width}x${viewport.height}`,
+ to: `${viewport.height}x${viewport.width}`
+ });
+ }
+
+ /**
+ * Set geolocation
+ */
+ async setGeolocation(page: Page, latitude: number, longitude: number): Promise {
+ logger.info('Setting geolocation', { latitude, longitude });
+
+ await page.context().setGeolocation({ latitude, longitude });
+
+ logger.debug('Geolocation set', { latitude, longitude });
+ }
+
+ /**
+ * Simulate touch gesture (tap)
+ */
+ async tap(page: Page, selector: string): Promise {
+ logger.debug('Simulating tap', { selector });
+
+ await page.tap(selector);
+
+ logger.debug('Tap completed', { selector });
+ }
+
+ /**
+ * Simulate swipe gesture
+ */
+ async swipe(
+ page: Page,
+ startX: number,
+ startY: number,
+ endX: number,
+ endY: number
+ ): Promise {
+ logger.debug('Simulating swipe', {
+ from: `${startX},${startY}`,
+ to: `${endX},${endY}`
+ });
+
+ await page.touchscreen.tap(startX, startY);
+ await page.mouse.move(endX, endY);
+
+ logger.debug('Swipe completed');
+ }
+
+ /**
+ * Simulate pinch zoom
+ */
+ async pinchZoom(page: Page, scale: number): Promise {
+ logger.debug('Simulating pinch zoom', { scale });
+
+ await page.evaluate((s: number) => {
+ const viewport = window.visualViewport;
+ if (viewport) {
+ (document.body as any).style.zoom = s;
+ }
+ }, scale);
+
+ logger.debug('Pinch zoom completed', { scale });
+ }
+
+ /**
+ * Test responsive breakpoints
+ */
+ async testBreakpoints(
+ page: Page,
+ breakpoints: Array<{ name: string; width: number; height: number }>,
+ testFn: (breakpoint: { name: string; width: number; height: number }) => Promise
+ ): Promise {
+ logger.info('Testing responsive breakpoints', { count: breakpoints.length });
+
+ for (const breakpoint of breakpoints) {
+ logger.info('Testing breakpoint', breakpoint);
+
+ await this.setViewport(page, breakpoint.width, breakpoint.height);
+ await page.waitForTimeout(500); // Wait for CSS transitions
+
+ await testFn(breakpoint);
+ }
+
+ logger.info('Breakpoint testing completed');
+ }
+
+ /**
+ * Common breakpoints for responsive testing
+ */
+ static readonly BREAKPOINTS = {
+ mobile_small: { name: 'Mobile Small', width: 320, height: 568 },
+ mobile: { name: 'Mobile', width: 375, height: 667 },
+ mobile_large: { name: 'Mobile Large', width: 414, height: 896 },
+ tablet_portrait: { name: 'Tablet Portrait', width: 768, height: 1024 },
+ tablet_landscape: { name: 'Tablet Landscape', width: 1024, height: 768 },
+ desktop_small: { name: 'Desktop Small', width: 1280, height: 720 },
+ desktop: { name: 'Desktop', width: 1920, height: 1080 },
+ desktop_large: { name: 'Desktop Large', width: 2560, height: 1440 }
+ };
+
+ /**
+ * Emulate network conditions (for mobile testing)
+ */
+ async emulateNetworkConditions(
+ page: Page,
+ profile: 'offline' | 'slow-3g' | 'fast-3g' | '4g' | 'wifi'
+ ): Promise {
+ logger.info('Emulating network conditions', { profile });
+
+ const conditions = {
+ 'offline': { offline: true, downloadThroughput: 0, uploadThroughput: 0, latency: 0 },
+ 'slow-3g': { offline: false, downloadThroughput: 50 * 1024, uploadThroughput: 50 * 1024, latency: 2000 },
+ 'fast-3g': { offline: false, downloadThroughput: 1.6 * 1024 * 1024, uploadThroughput: 750 * 1024, latency: 562.5 },
+ '4g': { offline: false, downloadThroughput: 4 * 1024 * 1024, uploadThroughput: 3 * 1024 * 1024, latency: 20 },
+ 'wifi': { offline: false, downloadThroughput: 30 * 1024 * 1024, uploadThroughput: 15 * 1024 * 1024, latency: 2 }
+ };
+
+ const condition = conditions[profile];
+ await page.context().setOffline(condition.offline);
+
+ logger.info('Network conditions set', { profile });
+ }
+
+ /**
+ * Check if element is visible in mobile viewport
+ */
+ async isVisibleInViewport(page: Page, selector: string): Promise {
+ return await page.evaluate((sel: string) => {
+ const element = document.querySelector(sel);
+ if (!element) return false;
+
+ const rect = element.getBoundingClientRect();
+ return (
+ rect.top >= 0 &&
+ rect.left >= 0 &&
+ rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
+ rect.right <= (window.innerWidth || document.documentElement.clientWidth)
+ );
+ }, selector);
+ }
+
+ /**
+ * Scroll to element (useful for mobile)
+ */
+ async scrollToElement(page: Page, selector: string): Promise {
+ logger.debug('Scrolling to element', { selector });
+
+ await page.locator(selector).scrollIntoViewIfNeeded();
+
+ logger.debug('Scrolled to element', { selector });
+ }
+
+ /**
+ * Get device info
+ */
+ async getDeviceInfo(page: Page): Promise<{
+ userAgent: string;
+ viewport: { width: number; height: number } | null;
+ devicePixelRatio: number;
+ touchSupport: boolean;
+ }> {
+ const info = await page.evaluate(() => ({
+ userAgent: navigator.userAgent,
+ viewport: {
+ width: window.innerWidth,
+ height: window.innerHeight
+ },
+ devicePixelRatio: window.devicePixelRatio,
+ touchSupport: 'ontouchstart' in window
+ }));
+
+ logger.debug('Device info retrieved', info);
+
+ return info;
+ }
+
+ /**
+ * Test touch interactions
+ */
+ async testTouchInteractions(page: Page, selector: string): Promise<{
+ tap: boolean;
+ longPress: boolean;
+ swipe: boolean;
+ }> {
+ logger.info('Testing touch interactions', { selector });
+
+ const results = {
+ tap: false,
+ longPress: false,
+ swipe: false
+ };
+
+ try {
+ // Test tap
+ await this.tap(page, selector);
+ results.tap = true;
+ logger.debug('Tap test passed');
+ } catch (error) {
+ logger.warn('Tap test failed', { error });
+ }
+
+ logger.info('Touch interaction tests completed', results);
+
+ return results;
+ }
+
+ /**
+ * Simulate mobile keyboard
+ */
+ async showMobileKeyboard(page: Page, inputSelector: string): Promise {
+ logger.debug('Showing mobile keyboard', { selector: inputSelector });
+
+ await page.focus(inputSelector);
+
+ // Simulate keyboard showing by reducing viewport
+ const viewport = page.viewportSize();
+ if (viewport) {
+ await page.setViewportSize({
+ width: viewport.width,
+ height: Math.floor(viewport.height * 0.6) // Keyboard takes ~40% of screen
+ });
+ }
+
+ logger.debug('Mobile keyboard shown');
+ }
+
+ /**
+ * Hide mobile keyboard
+ */
+ async hideMobileKeyboard(page: Page): Promise {
+ logger.debug('Hiding mobile keyboard');
+
+ await page.keyboard.press('Escape');
+
+ logger.debug('Mobile keyboard hidden');
+ }
+
+ /**
+ * List all available devices
+ */
+ static getAvailableDevices(): string[] {
+ return Object.keys(MobileHelper.DEVICES);
+ }
+
+ /**
+ * Get device by name
+ */
+ static getDevice(name: keyof typeof MobileHelper.DEVICES): any {
+ return MobileHelper.DEVICES[name];
+ }
+}
\ No newline at end of file
diff --git a/src/performance/performance-helper.ts b/src/performance/performance-helper.ts
new file mode 100644
index 0000000..0197783
--- /dev/null
+++ b/src/performance/performance-helper.ts
@@ -0,0 +1,430 @@
+import { Page } from '@playwright/test';
+import { logger } from '../utils/logger';
+
+export interface PerformanceMetrics {
+ domContentLoaded: number;
+ loadComplete: number;
+ firstPaint: number;
+ firstContentfulPaint: number;
+ largestContentfulPaint?: number;
+ timeToInteractive?: number;
+ totalBlockingTime?: number;
+ cumulativeLayoutShift?: number;
+}
+
+export interface ResourceTiming {
+ name: string;
+ type: string;
+ duration: number;
+ size: number;
+ startTime: number;
+}
+
+export interface PerformanceBudget {
+ domContentLoaded?: number;
+ loadComplete?: number;
+ firstContentfulPaint?: number;
+ largestContentfulPaint?: number;
+ timeToInteractive?: number;
+ totalBlockingTime?: number;
+ cumulativeLayoutShift?: number;
+}
+
+export interface PerformanceReport {
+ url: string;
+ timestamp: string;
+ metrics: PerformanceMetrics;
+ resources: ResourceTiming[];
+ budgetViolations: string[];
+ score: number;
+}
+
+/**
+ * Performance testing and monitoring helper
+ */
+export class PerformanceHelper {
+ private metrics: Map = new Map();
+ private reports: PerformanceReport[] = [];
+
+ constructor(private page: Page) {}
+
+ /**
+ * Measure page load performance
+ */
+ async measurePageLoad(): Promise {
+ logger.info('Measuring page load performance');
+
+ try {
+ const metrics = await this.page.evaluate(() => {
+ const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
+ const paint = performance.getEntriesByType('paint');
+
+ const firstPaint = paint.find(entry => entry.name === 'first-paint')?.startTime || 0;
+ const firstContentfulPaint = paint.find(entry => entry.name === 'first-contentful-paint')?.startTime || 0;
+
+ // Try to get Web Vitals
+ let lcp = 0;
+ let cls = 0;
+ let tti = 0;
+ let tbt = 0;
+
+ // LCP
+ try {
+ const lcpEntry = performance.getEntriesByType('largest-contentful-paint')[0];
+ lcp = lcpEntry ? lcpEntry.startTime : 0;
+ } catch (e) {}
+
+ // CLS (simplified calculation)
+ try {
+ const layoutShifts = performance.getEntriesByType('layout-shift');
+ cls = layoutShifts.reduce((sum: number, entry: any) => {
+ if (!entry.hadRecentInput) {
+ return sum + entry.value;
+ }
+ return sum;
+ }, 0);
+ } catch (e) {}
+
+ return {
+ domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart,
+ loadComplete: navigation.loadEventEnd - navigation.fetchStart,
+ firstPaint,
+ firstContentfulPaint,
+ largestContentfulPaint: lcp,
+ cumulativeLayoutShift: cls,
+ timeToInteractive: tti,
+ totalBlockingTime: tbt
+ };
+ });
+
+ const url = this.page.url();
+ this.metrics.set(url, metrics);
+
+ logger.info('Page load metrics captured', {
+ url,
+ domContentLoaded: `${metrics.domContentLoaded.toFixed(2)}ms`,
+ loadComplete: `${metrics.loadComplete.toFixed(2)}ms`,
+ firstContentfulPaint: `${metrics.firstContentfulPaint.toFixed(2)}ms`
+ });
+
+ return metrics;
+ } catch (error) {
+ logger.error('Failed to measure page load', { error });
+ throw error;
+ }
+ }
+
+ /**
+ * Measure API response time
+ */
+ async measureApiResponse(urlPattern: string): Promise {
+ logger.info('Measuring API response time', { pattern: urlPattern });
+
+ const startTime = Date.now();
+
+ try {
+ await this.page.waitForResponse(
+ response => response.url().includes(urlPattern),
+ { timeout: 30000 }
+ );
+
+ const duration = Date.now() - startTime;
+
+ logger.info('API response time measured', {
+ pattern: urlPattern,
+ duration: `${duration}ms`
+ });
+
+ return duration;
+ } catch (error) {
+ logger.error('Failed to measure API response', { pattern: urlPattern, error });
+ throw error;
+ }
+ }
+
+ /**
+ * Get resource timing details
+ */
+ async getResourceTimings(): Promise {
+ logger.debug('Collecting resource timings');
+
+ try {
+ const resources = await this.page.evaluate(() => {
+ const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
+
+ return entries.map(entry => ({
+ name: entry.name,
+ type: entry.initiatorType,
+ duration: entry.duration,
+ size: entry.transferSize || 0,
+ startTime: entry.startTime
+ }));
+ });
+
+ logger.debug('Resource timings collected', { count: resources.length });
+ return resources;
+ } catch (error) {
+ logger.error('Failed to get resource timings', { error });
+ return [];
+ }
+ }
+
+ /**
+ * Get slowest resources
+ */
+ async getSlowestResources(count: number = 10): Promise {
+ const resources = await this.getResourceTimings();
+
+ return resources
+ .sort((a, b) => b.duration - a.duration)
+ .slice(0, count);
+ }
+
+ /**
+ * Get largest resources
+ */
+ async getLargestResources(count: number = 10): Promise {
+ const resources = await this.getResourceTimings();
+
+ return resources
+ .sort((a, b) => b.size - a.size)
+ .slice(0, count);
+ }
+
+ /**
+ * Measure time to first byte (TTFB)
+ */
+ async measureTTFB(): Promise {
+ logger.info('Measuring Time to First Byte');
+
+ try {
+ const ttfb = await this.page.evaluate(() => {
+ const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
+ return navigation.responseStart - navigation.fetchStart;
+ });
+
+ logger.info('TTFB measured', { ttfb: `${ttfb.toFixed(2)}ms` });
+ return ttfb;
+ } catch (error) {
+ logger.error('Failed to measure TTFB', { error });
+ return 0;
+ }
+ }
+
+ /**
+ * Measure rendering metrics
+ */
+ async measureRenderingMetrics(): Promise<{
+ styleRecalculation: number;
+ layoutTime: number;
+ paintTime: number;
+ }> {
+ logger.info('Measuring rendering metrics');
+
+ try {
+ const metrics = await this.page.evaluate(() => {
+ const measures = performance.getEntriesByType('measure');
+
+ return {
+ styleRecalculation: 0, // Would need Performance Observer
+ layoutTime: 0,
+ paintTime: 0
+ };
+ });
+
+ return metrics;
+ } catch (error) {
+ logger.error('Failed to measure rendering', { error });
+ return { styleRecalculation: 0, layoutTime: 0, paintTime: 0 };
+ }
+ }
+
+ /**
+ * Check performance budget
+ */
+ async checkBudget(budget: PerformanceBudget): Promise {
+ logger.info('Checking performance budget');
+
+ const metrics = await this.measurePageLoad();
+ const violations: string[] = [];
+
+ if (budget.domContentLoaded && metrics.domContentLoaded > budget.domContentLoaded) {
+ violations.push(
+ `DOM Content Loaded exceeded budget: ${metrics.domContentLoaded.toFixed(2)}ms > ${budget.domContentLoaded}ms`
+ );
+ }
+
+ if (budget.loadComplete && metrics.loadComplete > budget.loadComplete) {
+ violations.push(
+ `Load Complete exceeded budget: ${metrics.loadComplete.toFixed(2)}ms > ${budget.loadComplete}ms`
+ );
+ }
+
+ if (budget.firstContentfulPaint && metrics.firstContentfulPaint > budget.firstContentfulPaint) {
+ violations.push(
+ `First Contentful Paint exceeded budget: ${metrics.firstContentfulPaint.toFixed(2)}ms > ${budget.firstContentfulPaint}ms`
+ );
+ }
+
+ if (budget.largestContentfulPaint && metrics.largestContentfulPaint &&
+ metrics.largestContentfulPaint > budget.largestContentfulPaint) {
+ violations.push(
+ `Largest Contentful Paint exceeded budget: ${metrics.largestContentfulPaint.toFixed(2)}ms > ${budget.largestContentfulPaint}ms`
+ );
+ }
+
+ if (budget.cumulativeLayoutShift && metrics.cumulativeLayoutShift &&
+ metrics.cumulativeLayoutShift > budget.cumulativeLayoutShift) {
+ violations.push(
+ `Cumulative Layout Shift exceeded budget: ${metrics.cumulativeLayoutShift.toFixed(4)} > ${budget.cumulativeLayoutShift}`
+ );
+ }
+
+ if (violations.length > 0) {
+ logger.warn('Performance budget violations detected', {
+ violations: violations.length
+ });
+ violations.forEach(v => logger.warn(v));
+ } else {
+ logger.info('All performance budgets met');
+ }
+
+ return violations;
+ }
+
+ /**
+ * Calculate performance score (0-100)
+ */
+ calculateScore(metrics: PerformanceMetrics): number {
+ // Simplified scoring based on Web Vitals
+ let score = 100;
+
+ // FCP scoring (< 1.8s = good)
+ if (metrics.firstContentfulPaint > 3000) score -= 30;
+ else if (metrics.firstContentfulPaint > 1800) score -= 15;
+
+ // LCP scoring (< 2.5s = good)
+ if (metrics.largestContentfulPaint) {
+ if (metrics.largestContentfulPaint > 4000) score -= 30;
+ else if (metrics.largestContentfulPaint > 2500) score -= 15;
+ }
+
+ // CLS scoring (< 0.1 = good)
+ if (metrics.cumulativeLayoutShift) {
+ if (metrics.cumulativeLayoutShift > 0.25) score -= 20;
+ else if (metrics.cumulativeLayoutShift > 0.1) score -= 10;
+ }
+
+ // Load time scoring
+ if (metrics.loadComplete > 5000) score -= 20;
+ else if (metrics.loadComplete > 3000) score -= 10;
+
+ return Math.max(0, score);
+ }
+
+ /**
+ * Generate performance report
+ */
+ async generateReport(): Promise {
+ logger.info('Generating performance report');
+
+ const url = this.page.url();
+ const metrics = await this.measurePageLoad();
+ const resources = await this.getResourceTimings();
+ const budget: PerformanceBudget = {
+ domContentLoaded: 2000,
+ loadComplete: 3000,
+ firstContentfulPaint: 1800,
+ largestContentfulPaint: 2500,
+ cumulativeLayoutShift: 0.1
+ };
+ const budgetViolations = await this.checkBudget(budget);
+ const score = this.calculateScore(metrics);
+
+ const report: PerformanceReport = {
+ url,
+ timestamp: new Date().toISOString(),
+ metrics,
+ resources,
+ budgetViolations,
+ score
+ };
+
+ this.reports.push(report);
+
+ logger.info('Performance report generated', {
+ url,
+ score,
+ violations: budgetViolations.length
+ });
+
+ return report;
+ }
+
+ /**
+ * Get all metrics
+ */
+ getAllMetrics(): Map {
+ return this.metrics;
+ }
+
+ /**
+ * Get all reports
+ */
+ getAllReports(): PerformanceReport[] {
+ return this.reports;
+ }
+
+ /**
+ * Clear metrics
+ */
+ clearMetrics(): void {
+ this.metrics.clear();
+ this.reports = [];
+ logger.debug('Performance metrics cleared');
+ }
+
+ /**
+ * Export metrics to JSON
+ */
+ exportMetrics(): string {
+ return JSON.stringify({
+ metrics: Array.from(this.metrics.entries()).map(([url, metrics]) => ({
+ url,
+ metrics
+ })),
+ reports: this.reports
+ }, null, 2);
+ }
+
+ /**
+ * Monitor continuous metrics
+ */
+ async startMonitoring(interval: number = 1000): Promise {
+ logger.info('Starting performance monitoring', { interval });
+
+ // Note: This is a simplified version
+ // For production, consider using Performance Observer API
+
+ const monitor = setInterval(async () => {
+ try {
+ await this.measurePageLoad();
+ } catch (error) {
+ logger.error('Monitoring error', { error });
+ }
+ }, interval);
+
+ // Store interval ID for cleanup
+ (this as any).monitorInterval = monitor;
+ }
+
+ /**
+ * Stop monitoring
+ */
+ stopMonitoring(): void {
+ if ((this as any).monitorInterval) {
+ clearInterval((this as any).monitorInterval);
+ logger.info('Performance monitoring stopped');
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts
new file mode 100644
index 0000000..fd6d9d6
--- /dev/null
+++ b/src/utils/error-handler.ts
@@ -0,0 +1,218 @@
+import { Page } from '@playwright/test';
+import { logger } from './logger';
+
+export interface ErrorContext {
+ action: string;
+ selector?: string;
+ url?: string;
+ additionalInfo?: any;
+}
+
+export class TestError extends Error {
+ constructor(
+ message: string,
+ public context: ErrorContext,
+ public originalError?: Error
+ ) {
+ super(message);
+ this.name = 'TestError';
+
+ // Capture stack trace
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, TestError);
+ }
+ }
+}
+
+export class ErrorHandler {
+ constructor(private page: Page) {}
+
+ /**
+ * Wrap an action with error handling
+ */
+ async wrapAction(
+ action: () => Promise,
+ context: ErrorContext
+ ): Promise {
+ try {
+ return await action();
+ } catch (error) {
+ await this.handleError(error, context);
+ throw error;
+ }
+ }
+
+ /**
+ * Handle errors with enhanced logging and diagnostics
+ */
+ async handleError(error: any, context: ErrorContext): Promise {
+ const errorMessage = error.message || 'Unknown error';
+
+ logger.error('Test action failed', {
+ action: context.action,
+ selector: context.selector,
+ url: await this.page.url(),
+ error: errorMessage,
+ stack: error.stack,
+ ...context.additionalInfo
+ });
+
+ // Capture diagnostic information
+ try {
+ const diagnostics = await this.captureDiagnostics();
+ logger.debug('Diagnostics captured', diagnostics);
+ } catch (diagError) {
+ logger.warn('Failed to capture diagnostics', { error: diagError });
+ }
+ }
+
+ /**
+ * Capture diagnostic information about the page state
+ */
+ async captureDiagnostics() {
+ try {
+ return {
+ url: this.page.url(),
+ title: await this.page.title(),
+ viewport: this.page.viewportSize(),
+ cookies: await this.page.context().cookies(),
+ localStorage: await this.page.evaluate(() =>
+ JSON.stringify(localStorage)
+ ),
+ sessionStorage: await this.page.evaluate(() =>
+ JSON.stringify(sessionStorage)
+ ),
+ userAgent: await this.page.evaluate(() => navigator.userAgent),
+ timestamp: new Date().toISOString()
+ };
+ } catch (error) {
+ return { error: 'Failed to capture diagnostics' };
+ }
+ }
+
+ /**
+ * Retry an action with exponential backoff
+ */
+ async retryAction(
+ action: () => Promise,
+ options: {
+ maxRetries?: number;
+ initialDelay?: number;
+ maxDelay?: number;
+ context: ErrorContext;
+ }
+ ): Promise {
+ const maxRetries = options.maxRetries || 3;
+ const initialDelay = options.initialDelay || 1000;
+ const maxDelay = options.maxDelay || 10000;
+
+ let lastError: Error | undefined;
+
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ try {
+ logger.debug(`Attempting action (${attempt}/${maxRetries})`, options.context);
+ return await action();
+ } catch (error) {
+ lastError = error as Error;
+
+ if (attempt === maxRetries) {
+ logger.error('Max retries reached', {
+ ...options.context,
+ attempts: attempt,
+ error: lastError.message
+ });
+ break;
+ }
+
+ const delay = Math.min(initialDelay * Math.pow(2, attempt - 1), maxDelay);
+ logger.warn(`Action failed, retrying in ${delay}ms`, {
+ ...options.context,
+ attempt,
+ error: lastError.message
+ });
+
+ await this.page.waitForTimeout(delay);
+ }
+ }
+
+ throw new TestError(
+ `Action failed after ${maxRetries} attempts: ${lastError?.message}`,
+ options.context,
+ lastError
+ );
+ }
+
+ /**
+ * Assert condition with custom error message
+ */
+ async assertCondition(
+ condition: () => Promise,
+ errorMessage: string,
+ context: ErrorContext
+ ): Promise {
+ try {
+ const result = await condition();
+ if (!result) {
+ throw new TestError(errorMessage, context);
+ }
+ } catch (error) {
+ if (error instanceof TestError) {
+ throw error;
+ }
+ throw new TestError(errorMessage, context, error as Error);
+ }
+ }
+
+ /**
+ * Wait for condition with timeout
+ */
+ async waitForCondition(
+ condition: () => Promise,
+ options: {
+ timeout?: number;
+ interval?: number;
+ errorMessage?: string;
+ context: ErrorContext;
+ }
+ ): Promise {
+ const timeout = options.timeout || 30000;
+ const interval = options.interval || 500;
+ const errorMessage = options.errorMessage || 'Condition not met within timeout';
+
+ const startTime = Date.now();
+
+ while (Date.now() - startTime < timeout) {
+ try {
+ if (await condition()) {
+ return;
+ }
+ } catch (error) {
+ // Ignore errors during polling
+ }
+
+ await this.page.waitForTimeout(interval);
+ }
+
+ throw new TestError(errorMessage, options.context);
+ }
+}
+
+/**
+ * Global error handler for uncaught exceptions
+ */
+export function setupGlobalErrorHandlers(): void {
+ process.on('unhandledRejection', (reason, promise) => {
+ logger.error('Unhandled Promise Rejection', {
+ reason,
+ promise
+ });
+ });
+
+ process.on('uncaughtException', (error) => {
+ logger.error('Uncaught Exception', {
+ error: error.message,
+ stack: error.stack
+ });
+ process.exit(1);
+ });
+}
\ No newline at end of file
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
new file mode 100644
index 0000000..d63fa59
--- /dev/null
+++ b/src/utils/logger.ts
@@ -0,0 +1,155 @@
+import winston from 'winston';
+import path from 'path';
+import fs from 'fs';
+import { config } from '../../config/test.config';
+
+// Ensure logs directory exists
+const logsDir = config.logs.dir;
+if (!fs.existsSync(logsDir)) {
+ fs.mkdirSync(logsDir, { recursive: true });
+}
+
+// Custom format for console output with better readability
+const consoleFormat = winston.format.combine(
+ winston.format.timestamp({ format: 'HH:mm:ss' }),
+ winston.format.colorize(),
+ winston.format.printf((info) => {
+ const { timestamp, level, message, scenario, action, selector, url, ...meta } = info;
+ let output = `[${timestamp}] ${level}:`;
+
+ // Add scenario name if present
+ if (scenario) {
+ output += ` [${scenario}]`;
+ }
+
+ // Add the main message
+ output += ` ${message}`;
+
+ // Add relevant metadata inline
+ if (action) output += ` | Action: ${action}`;
+ if (selector) output += ` | Element: ${selector}`;
+ if (url && typeof message === 'string' && message.toLowerCase().includes('url')) {
+ output += ` | URL: ${url}`;
+ }
+
+ // Add remaining metadata on new lines if needed
+ const remainingMeta = { ...meta };
+ delete remainingMeta.service;
+ delete remainingMeta.timestamp;
+
+ if (Object.keys(remainingMeta).length > 0) {
+ const metaStr = Object.entries(remainingMeta)
+ .map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)
+ .join('\n');
+ if (metaStr) output += '\n' + metaStr;
+ }
+
+ return output;
+ })
+);
+
+// Format for file output (JSON for parsing)
+const fileFormat = winston.format.combine(
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ winston.format.errors({ stack: true }),
+ winston.format.json()
+);
+
+// Create the logger
+export const logger = winston.createLogger({
+ level: config.logs.level,
+ format: fileFormat,
+ defaultMeta: { service: 'playwright-cucumber-tests' },
+ transports: [
+ // Error logs
+ new winston.transports.File({
+ filename: path.join(logsDir, 'error.log'),
+ level: 'error',
+ maxsize: 5242880, // 5MB
+ maxFiles: 5
+ }),
+ // Combined logs
+ new winston.transports.File({
+ filename: path.join(logsDir, 'combined.log'),
+ maxsize: 5242880, // 5MB
+ maxFiles: 5
+ }),
+ // Console output with better formatting
+ new winston.transports.Console({
+ format: consoleFormat,
+ level: process.env.CI === 'true' ? 'warn' : config.logs.level
+ })
+ ]
+});
+
+// Create scenario-specific logger
+export class ScenarioLogger {
+ private scenarioName: string;
+
+ constructor(scenarioName: string) {
+ this.scenarioName = scenarioName;
+ }
+
+ info(message: string, meta?: any) {
+ logger.info(message, { scenario: this.scenarioName, ...meta });
+ }
+
+ warn(message: string, meta?: any) {
+ logger.warn(message, { scenario: this.scenarioName, ...meta });
+ }
+
+ error(message: string, meta?: any) {
+ logger.error(message, { scenario: this.scenarioName, ...meta });
+ }
+
+ debug(message: string, meta?: any) {
+ logger.debug(message, { scenario: this.scenarioName, ...meta });
+ }
+
+ step(stepText: string) {
+ // Use a distinctive format for steps
+ logger.info(`š STEP: ${stepText}`, {
+ scenario: this.scenarioName,
+ type: 'step'
+ });
+ }
+
+ action(actionName: string, details?: any) {
+ logger.info(`š§ ${actionName}`, {
+ scenario: this.scenarioName,
+ type: 'action',
+ ...details
+ });
+ }
+
+ assertion(description: string, details?: any) {
+ logger.info(`ā ${description}`, {
+ scenario: this.scenarioName,
+ type: 'assertion',
+ ...details
+ });
+ }
+}
+
+// Helper to log test execution summary
+export function logTestSummary(stats: {
+ total: number;
+ passed: number;
+ failed: number;
+ skipped: number;
+ duration: number;
+}) {
+ const line = 'ā'.repeat(80);
+ logger.info(line);
+ logger.info('š TEST EXECUTION SUMMARY');
+ logger.info(line);
+ logger.info(`Total Scenarios: ${stats.total}`);
+ logger.info(`ā
Passed: ${stats.passed}`);
+ logger.info(`ā Failed: ${stats.failed}`);
+ logger.info(`āļø Skipped: ${stats.skipped}`);
+ logger.info(`ā±ļø Duration: ${stats.duration}ms`);
+ logger.info(line);
+}
+
+// Export default logger
+export default logger;
\ No newline at end of file
diff --git a/src/visual/visual-testing.ts b/src/visual/visual-testing.ts
new file mode 100644
index 0000000..d496978
--- /dev/null
+++ b/src/visual/visual-testing.ts
@@ -0,0 +1,350 @@
+import { Page, Locator, expect } from '@playwright/test';
+import { logger } from '../utils/logger';
+import * as fs from 'fs';
+import * as path from 'path';
+
+export interface VisualCompareOptions {
+ maxDiffPixels?: number;
+ maxDiffPixelRatio?: number;
+ threshold?: number;
+ animations?: 'disabled' | 'allow';
+ mask?: Locator[];
+ fullPage?: boolean;
+}
+
+export interface VisualTestResult {
+ name: string;
+ passed: boolean;
+ diffPixels?: number;
+ diffRatio?: number;
+ actualPath?: string;
+ expectedPath?: string;
+ diffPath?: string;
+}
+
+/**
+ * Visual regression testing helper
+ */
+export class VisualTesting {
+ private snapshotDir: string;
+ private results: VisualTestResult[] = [];
+
+ constructor(private page: Page) {
+ this.snapshotDir = path.join(process.cwd(), 'test-results', 'screenshots', 'snapshots');
+ this.ensureSnapshotDir();
+ }
+
+ /**
+ * Ensure snapshot directory exists
+ */
+ private ensureSnapshotDir(): void {
+ if (!fs.existsSync(this.snapshotDir)) {
+ fs.mkdirSync(this.snapshotDir, { recursive: true });
+ }
+ }
+
+ /**
+ * Compare full page screenshot with baseline
+ */
+ async compareFullPage(name: string, options?: VisualCompareOptions): Promise {
+ logger.info('Comparing full page screenshot', { name });
+
+ try {
+ await expect(this.page).toHaveScreenshot(`${name}.png`, {
+ maxDiffPixels: options?.maxDiffPixels || 100,
+ maxDiffPixelRatio: options?.maxDiffPixelRatio,
+ threshold: options?.threshold || 0.2,
+ animations: options?.animations || 'disabled',
+ mask: options?.mask,
+ fullPage: options?.fullPage !== false
+ });
+
+ this.results.push({
+ name,
+ passed: true
+ });
+
+ logger.info('Visual comparison passed', { name });
+ } catch (error: any) {
+ logger.error('Visual comparison failed', {
+ name,
+ error: error.message
+ });
+
+ this.results.push({
+ name,
+ passed: false,
+ diffPixels: error.matcherResult?.diffPixels,
+ diffRatio: error.matcherResult?.diffRatio
+ });
+
+ throw error;
+ }
+ }
+
+ /**
+ * Compare specific element screenshot
+ */
+ async compareElement(selector: string, name: string, options?: VisualCompareOptions): Promise {
+ logger.info('Comparing element screenshot', { selector, name });
+
+ try {
+ const element = this.page.locator(selector);
+ await element.scrollIntoViewIfNeeded();
+
+ await expect(element).toHaveScreenshot(`${name}-element.png`, {
+ maxDiffPixels: options?.maxDiffPixels || 50,
+ maxDiffPixelRatio: options?.maxDiffPixelRatio,
+ threshold: options?.threshold || 0.2,
+ animations: options?.animations || 'disabled',
+ mask: options?.mask
+ });
+
+ this.results.push({
+ name: `${name}-element`,
+ passed: true
+ });
+
+ logger.info('Element visual comparison passed', { selector, name });
+ } catch (error: any) {
+ logger.error('Element visual comparison failed', {
+ selector,
+ name,
+ error: error.message
+ });
+
+ this.results.push({
+ name: `${name}-element`,
+ passed: false,
+ diffPixels: error.matcherResult?.diffPixels,
+ diffRatio: error.matcherResult?.diffRatio
+ });
+
+ throw error;
+ }
+ }
+
+ /**
+ * Compare multiple elements
+ */
+ async compareElements(selectors: string[], baseName: string, options?: VisualCompareOptions): Promise {
+ logger.info('Comparing multiple elements', { count: selectors.length, baseName });
+
+ for (let i = 0; i < selectors.length; i++) {
+ const name = `${baseName}-${i + 1}`;
+ await this.compareElement(selectors[i], name, options);
+ }
+ }
+
+ /**
+ * Compare viewport screenshot (visible area only)
+ */
+ async compareViewport(name: string, options?: VisualCompareOptions): Promise {
+ logger.info('Comparing viewport screenshot', { name });
+
+ try {
+ await expect(this.page).toHaveScreenshot(`${name}-viewport.png`, {
+ maxDiffPixels: options?.maxDiffPixels || 100,
+ maxDiffPixelRatio: options?.maxDiffPixelRatio,
+ threshold: options?.threshold || 0.2,
+ animations: options?.animations || 'disabled',
+ mask: options?.mask,
+ fullPage: false
+ });
+
+ this.results.push({
+ name: `${name}-viewport`,
+ passed: true
+ });
+
+ logger.info('Viewport visual comparison passed', { name });
+ } catch (error: any) {
+ logger.error('Viewport visual comparison failed', {
+ name,
+ error: error.message
+ });
+
+ this.results.push({
+ name: `${name}-viewport`,
+ passed: false
+ });
+
+ throw error;
+ }
+ }
+
+ /**
+ * Mask dynamic elements before comparison
+ */
+ async compareWithMask(name: string, maskSelectors: string[], options?: VisualCompareOptions): Promise {
+ logger.info('Comparing with masked elements', {
+ name,
+ maskCount: maskSelectors.length
+ });
+
+ const masks = maskSelectors.map(selector => this.page.locator(selector));
+
+ await this.compareFullPage(name, {
+ ...options,
+ mask: masks
+ });
+ }
+
+ /**
+ * Compare after hiding elements
+ */
+ async compareWithHiddenElements(name: string, hideSelectors: string[], options?: VisualCompareOptions): Promise {
+ logger.info('Comparing with hidden elements', {
+ name,
+ hideCount: hideSelectors.length
+ });
+
+ // Hide elements
+ for (const selector of hideSelectors) {
+ await this.page.locator(selector).evaluate((el: HTMLElement) => {
+ el.style.visibility = 'hidden';
+ });
+ }
+
+ await this.compareFullPage(name, options);
+
+ // Restore elements
+ for (const selector of hideSelectors) {
+ await this.page.locator(selector).evaluate((el: HTMLElement) => {
+ el.style.visibility = 'visible';
+ });
+ }
+ }
+
+ /**
+ * Compare at different viewport sizes
+ */
+ async compareResponsive(
+ name: string,
+ viewports: Array<{ width: number; height: number; name: string }>,
+ options?: VisualCompareOptions
+ ): Promise {
+ logger.info('Comparing responsive views', {
+ name,
+ viewportCount: viewports.length
+ });
+
+ for (const viewport of viewports) {
+ await this.page.setViewportSize({
+ width: viewport.width,
+ height: viewport.height
+ });
+
+ // Wait for any CSS transitions
+ await this.page.waitForTimeout(500);
+
+ await this.compareFullPage(`${name}-${viewport.name}`, options);
+ }
+ }
+
+ /**
+ * Compare hover state
+ */
+ async compareHoverState(selector: string, name: string, options?: VisualCompareOptions): Promise {
+ logger.info('Comparing hover state', { selector, name });
+
+ const element = this.page.locator(selector);
+ await element.hover();
+
+ // Wait for hover animations
+ await this.page.waitForTimeout(300);
+
+ await this.compareElement(selector, `${name}-hover`, options);
+ }
+
+ /**
+ * Compare focus state
+ */
+ async compareFocusState(selector: string, name: string, options?: VisualCompareOptions): Promise {
+ logger.info('Comparing focus state', { selector, name });
+
+ const element = this.page.locator(selector);
+ await element.focus();
+
+ // Wait for focus styles
+ await this.page.waitForTimeout(200);
+
+ await this.compareElement(selector, `${name}-focus`, options);
+ }
+
+ /**
+ * Update baseline (accept current as new baseline)
+ */
+ async updateBaseline(name: string): Promise {
+ logger.warn('Updating baseline', { name });
+
+ // Playwright automatically updates baselines with --update-snapshots flag
+ // This method is for logging/documentation purposes
+
+ logger.info('To update baselines, run tests with --update-snapshots flag');
+ }
+
+ /**
+ * Get all test results
+ */
+ getResults(): VisualTestResult[] {
+ return this.results;
+ }
+
+ /**
+ * Get failed tests
+ */
+ getFailedTests(): VisualTestResult[] {
+ return this.results.filter(r => !r.passed);
+ }
+
+ /**
+ * Get summary
+ */
+ getSummary(): { total: number; passed: number; failed: number } {
+ const total = this.results.length;
+ const passed = this.results.filter(r => r.passed).length;
+ const failed = total - passed;
+
+ return { total, passed, failed };
+ }
+
+ /**
+ * Clear results
+ */
+ clearResults(): void {
+ this.results = [];
+ logger.debug('Visual test results cleared');
+ }
+
+ /**
+ * Compare with custom diff threshold
+ */
+ async compareWithThreshold(name: string, thresholdPercent: number, options?: VisualCompareOptions): Promise {
+ logger.info('Comparing with custom threshold', {
+ name,
+ threshold: thresholdPercent
+ });
+
+ await this.compareFullPage(name, {
+ ...options,
+ threshold: thresholdPercent / 100
+ });
+ }
+
+ /**
+ * Batch compare multiple pages
+ */
+ async batchCompare(
+ pages: Array<{ url: string; name: string }>,
+ options?: VisualCompareOptions
+ ): Promise {
+ logger.info('Batch comparing pages', { count: pages.length });
+
+ for (const pageInfo of pages) {
+ await this.page.goto(pageInfo.url);
+ await this.page.waitForLoadState('networkidle');
+ await this.compareFullPage(pageInfo.name, options);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/web/actions.ts b/src/web/actions.ts
index 0ea97c2..d873da9 100644
--- a/src/web/actions.ts
+++ b/src/web/actions.ts
@@ -1,255 +1,315 @@
-import { Page, ElementHandle, Frame, Response, Locator, Dialog } from '@playwright/test';
-import { Logger } from '../reporting/logger';
-
-type WaitForOptions = 'load' | 'domcontentloaded' | 'networkidle';
-
-/**
- * Custom error class for web actions
- */
-export class WebActionError extends Error {
- constructor(message: string, public readonly action: string, public readonly selector?: string) {
- super(`WebAction Failed: ${action}${selector ? ` on '${selector}'` : ''} - ${message}`);
- this.name = 'WebActionError';
- }
-}
-
-/**
- * Options for web actions
- */
-export interface WebActionOptions {
- timeout?: number;
- force?: boolean;
- noWaitAfter?: boolean;
- strict?: boolean;
- trial?: boolean;
- waitUntil?: WaitForOptions;
- modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[];
-}
-
-/**
- * WebActions class provides a wrapper around Playwright's Page with enhanced error handling
- */
+import { Page, Locator } from '@playwright/test';
+import { ErrorHandler, ErrorContext } from '../utils/error-handler';
+import { logger } from '../utils/logger';
+
export class WebActions {
- private readonly page: Page;
- private readonly logger: Logger;
- private readonly defaultTimeout: number;
+ private errorHandler: ErrorHandler;
- constructor(page: Page, options?: { defaultTimeout?: number }) {
- this.page = page;
- this.logger = new Logger('WebActions');
- this.defaultTimeout = options?.defaultTimeout ?? 30000;
+ constructor(private page: Page) {
+ this.errorHandler = new ErrorHandler(page);
}
/**
- * Safely execute a page action with error handling and logging
+ * Navigate to a URL
*/
- private async executeAction(
- action: string,
- selector: string | undefined,
- callback: () => Promise,
- details?: Record
- ): Promise {
- const startTime = Date.now();
- try {
- // Log action start at debug level
- const logMessage = `Executing ${action}${selector ? ` on '${selector}'` : ''}${
- details ? ` with details: ${JSON.stringify(details)}` : ''
- }`;
- this.logger.debug(`šµ START: ${logMessage}`);
-
- // Execute the action
- const result = await callback();
-
- // Log completion at info level with concise message
- const duration = Date.now() - startTime;
- this.logger.info(`ā ${action}${selector ? ` on '${selector}'` : ''} (${duration}ms)`);
- // Log detailed success at debug level
- this.logger.debug(`ā
SUCCESS: ${logMessage}`);
-
- return result;
- } catch (error: any) {
- // Log action failure at error level
- const duration = Date.now() - startTime;
- const message = error instanceof Error ? error.message : String(error);
- this.logger.error(`ā FAILED: ${action}${selector ? ` on '${selector}'` : ''} - ${message} (${duration}ms)`);
- throw new WebActionError(message, action, selector);
- }
+ async navigateTo(url: string, options?: { waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' }): Promise {
+ const context: ErrorContext = { action: 'navigate', url };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`š Navigating to URL`, { url });
+ await this.page.goto(url, {
+ waitUntil: options?.waitUntil || 'domcontentloaded',
+ timeout: 30000
+ });
+ logger.debug(`ā Page loaded successfully`, { url });
+ }, context);
+ }
+
+ /**
+ * Click on an element
+ */
+ async click(selector: string, options?: { timeout?: number; force?: boolean }): Promise {
+ const context: ErrorContext = { action: 'click', selector };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`š±ļø Clicking element`, { selector });
+ await this.page.locator(selector).click({
+ timeout: options?.timeout || 10000,
+ force: options?.force
+ });
+ logger.debug(`ā Click successful`, { selector });
+ }, context);
+ }
+
+ /**
+ * Fill an input field
+ */
+ async fill(selector: string, value: string, options?: { timeout?: number; clear?: boolean }): Promise {
+ const context: ErrorContext = { action: 'fill', selector, additionalInfo: { value: '***' } };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`āØļø Filling input field`, { selector, valueLength: value.length });
+
+ if (options?.clear) {
+ await this.page.locator(selector).clear();
+ }
+
+ await this.page.locator(selector).fill(value, {
+ timeout: options?.timeout || 10000
+ });
+ logger.debug(`ā Input filled`, { selector });
+ }, context);
+ }
+
+ /**
+ * Type text with delay (simulates human typing)
+ */
+ async type(selector: string, text: string, options?: { delay?: number }): Promise {
+ const context: ErrorContext = { action: 'type', selector };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`Typing into element: ${selector}`);
+ await this.page.locator(selector).pressSequentially(text, {
+ delay: options?.delay || 50
+ });
+ logger.debug(`Successfully typed into: ${selector}`);
+ }, context);
+ }
+
+ /**
+ * Select an option from a dropdown
+ */
+ async select(selector: string, value: string | string[]): Promise {
+ const context: ErrorContext = { action: 'select', selector };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`Selecting option in: ${selector}`);
+ await this.page.locator(selector).selectOption(value);
+ logger.debug(`Successfully selected option in: ${selector}`);
+ }, context);
}
/**
- * Navigate to a URL with enhanced error handling
+ * Wait for an element to be visible
*/
- async navigateTo(url: string, options?: WebActionOptions): Promise {
- const details = {
- waitUntil: options?.waitUntil ?? 'load',
- timeout: options?.timeout ?? this.defaultTimeout
- };
- return await this.executeAction('navigate', url, () =>
- this.page.goto(url, details),
- details
- );
+ async waitForSelector(selector: string, options?: { timeout?: number; state?: 'visible' | 'hidden' | 'attached' }): Promise {
+ const context: ErrorContext = { action: 'waitForSelector', selector };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`ā³ Waiting for element`, { selector, state: options?.state || 'visible' });
+ await this.page.locator(selector).waitFor({
+ state: options?.state || 'visible',
+ timeout: options?.timeout || 30000
+ });
+ logger.debug(`ā Element ready`, { selector });
+ }, context);
}
/**
- * Click an element with enhanced error handling
+ * Wait for a specific amount of time
*/
- async click(selector: string, options?: WebActionOptions): Promise {
- const details = {
- timeout: options?.timeout ?? this.defaultTimeout,
- force: options?.force,
- noWaitAfter: options?.noWaitAfter,
- strict: options?.strict,
- trial: options?.trial,
- modifiers: options?.modifiers
- };
- await this.executeAction('click', selector, () =>
- this.page.click(selector, details),
- details
- );
+ async waitForTimeout(timeout: number): Promise {
+ logger.debug(`Waiting for ${timeout}ms`);
+ await this.page.waitForTimeout(timeout);
}
/**
- * Fill a form field with text
+ * Wait for page load
*/
- async fill(selector: string, text: string, options?: WebActionOptions): Promise {
- const details = {
- timeout: options?.timeout ?? this.defaultTimeout,
- force: options?.force,
- noWaitAfter: options?.noWaitAfter,
- value: text // Include the text being filled for logging
- };
- await this.executeAction('fill', selector, () =>
- this.page.fill(selector, text, details),
- details
- );
+ async waitForPageLoad(): Promise {
+ const context: ErrorContext = { action: 'waitForPageLoad' };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug('Waiting for page load');
+ await this.page.waitForLoadState('domcontentloaded');
+ logger.debug('Page loaded');
+ }, context);
}
/**
- * Check if an element is visible
+ * Wait for network idle
*/
- async isVisible(selector: string, options?: WebActionOptions): Promise {
- const details = {
- timeout: options?.timeout ?? this.defaultTimeout
- };
- return await this.executeAction('isVisible', selector, () =>
- this.page.isVisible(selector, details),
- details
- );
+ async waitForNetworkIdle(): Promise {
+ const context: ErrorContext = { action: 'waitForNetworkIdle' };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug('Waiting for network idle');
+ await this.page.waitForLoadState('networkidle');
+ logger.debug('Network is idle');
+ }, context);
}
/**
- * Get text content from an element
+ * Get text content of an element
*/
- async getText(selector: string, options?: WebActionOptions): Promise {
- return await this.executeAction('getText', selector, async () => {
- const element = await this.page.$(selector);
- const text = element ? await element.textContent() : null;
+ async getText(selector: string): Promise {
+ const context: ErrorContext = { action: 'getText', selector };
+
+ return await this.errorHandler.wrapAction(async () => {
+ logger.debug(`Getting text from: ${selector}`);
+ const text = await this.page.locator(selector).textContent();
+ logger.debug(`Text retrieved: ${text?.substring(0, 50)}`);
return text;
- }, { returnValue: 'text content' });
+ }, context);
}
/**
- * Wait for an element to be visible
+ * Get attribute value of an element
*/
- async waitForElement(selector: string, options?: WebActionOptions): Promise {
- const details = {
- state: 'visible' as const,
- timeout: options?.timeout ?? this.defaultTimeout
- };
- await this.executeAction('waitForElement', selector, () =>
- this.page.waitForSelector(selector, details),
- details
- );
- }
-
- /**
- * Select an option in a dropdown
- */
- async selectOption(selector: string, value: string, options?: WebActionOptions): Promise {
- const details = {
- value,
- timeout: options?.timeout ?? this.defaultTimeout,
- force: options?.force,
- noWaitAfter: options?.noWaitAfter
- };
- await this.executeAction('selectOption', selector, () =>
- this.page.selectOption(selector, value, details),
- details
- );
- }
-
- /**
- * Take a screenshot of the page or element
- */
- async screenshot(path: string, selector?: string): Promise {
- const details = { path, targetType: selector ? 'element' : 'page' };
- await this.executeAction('screenshot', selector, async () => {
- if (selector) {
- const element = await this.page.$(selector);
- if (element) {
- await element.screenshot({ path });
- }
- } else {
- await this.page.screenshot({ path });
- }
- }, details);
+ async getAttribute(selector: string, attribute: string): Promise {
+ const context: ErrorContext = { action: 'getAttribute', selector, additionalInfo: { attribute } };
+
+ return await this.errorHandler.wrapAction(async () => {
+ logger.debug(`Getting attribute '${attribute}' from: ${selector}`);
+ return await this.page.locator(selector).getAttribute(attribute);
+ }, context);
}
/**
- * Press a keyboard key
+ * Check if element is visible
*/
- async pressKey(key: string, options?: { delay?: number }): Promise {
- const details = { key, ...options };
- await this.executeAction('pressKey', undefined, () =>
- this.page.keyboard.press(key, options),
- details
- );
+ async isVisible(selector: string, timeout?: number): Promise {
+ try {
+ await this.page.locator(selector).waitFor({
+ state: 'visible',
+ timeout: timeout || 5000
+ });
+ return true;
+ } catch {
+ return false;
+ }
}
/**
- * Handle a dialog (alert, confirm, prompt)
+ * Check if element is enabled
*/
- async handleDialog(callback: (dialog: Dialog) => Promise): Promise {
- await this.executeAction('handleDialog', undefined, () =>
- new Promise(resolve => {
- this.page.once('dialog', async dialog => {
- this.logger.info(`š¢ Dialog appeared: ${dialog.type()} - ${dialog.message()}`);
- await callback(dialog);
- resolve();
- });
- }),
- { handlerType: 'dialog' }
- );
+ async isEnabled(selector: string): Promise {
+ return await this.page.locator(selector).isEnabled();
+ }
+
+ /**
+ * Check if checkbox/radio is checked
+ */
+ async isChecked(selector: string): Promise {
+ return await this.page.locator(selector).isChecked();
+ }
+
+ /**
+ * Hover over an element
+ */
+ async hover(selector: string): Promise {
+ const context: ErrorContext = { action: 'hover', selector };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`Hovering over: ${selector}`);
+ await this.page.locator(selector).hover();
+ logger.debug(`Successfully hovered: ${selector}`);
+ }, context);
}
/**
* Double click an element
*/
- async doubleClick(selector: string, options?: WebActionOptions): Promise {
- await this.executeAction('doubleClick', selector, () =>
- this.page.dblclick(selector, {
- timeout: options?.timeout ?? this.defaultTimeout,
- force: options?.force,
- noWaitAfter: options?.noWaitAfter,
- strict: options?.strict,
- trial: options?.trial
- })
- );
- }
-
- /**
- * Right click an element
- */
- async rightClick(selector: string, options?: WebActionOptions): Promise {
- await this.executeAction('rightClick', selector, () =>
- this.page.click(selector, {
- timeout: options?.timeout ?? this.defaultTimeout,
- force: options?.force,
- noWaitAfter: options?.noWaitAfter,
- strict: options?.strict,
- trial: options?.trial,
- button: 'right'
- })
- );
+ async doubleClick(selector: string): Promise {
+ const context: ErrorContext = { action: 'doubleClick', selector };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`Double clicking: ${selector}`);
+ await this.page.locator(selector).dblclick();
+ logger.debug(`Successfully double clicked: ${selector}`);
+ }, context);
+ }
+
+ /**
+ * Press a keyboard key
+ */
+ async press(selector: string, key: string): Promise {
+ const context: ErrorContext = { action: 'press', selector, additionalInfo: { key } };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`Pressing key '${key}' on: ${selector}`);
+ await this.page.locator(selector).press(key);
+ logger.debug(`Successfully pressed key: ${key}`);
+ }, context);
+ }
+
+ /**
+ * Scroll to an element
+ */
+ async scrollToElement(selector: string): Promise {
+ const context: ErrorContext = { action: 'scrollToElement', selector };
+
+ await this.errorHandler.wrapAction(async () => {
+ logger.debug(`Scrolling to element: ${selector}`);
+ await this.page.locator(selector).scrollIntoViewIfNeeded();
+ logger.debug(`Successfully scrolled to: ${selector}`);
+ }, context);
+ }
+
+ /**
+ * Take a screenshot
+ */
+ async screenshot(options?: { path?: string; fullPage?: boolean }): Promise {
+ logger.debug('Taking screenshot');
+ return await this.page.screenshot({
+ path: options?.path,
+ fullPage: options?.fullPage || false
+ });
+ }
+
+ /**
+ * Get current URL
+ */
+ getCurrentUrl(): string {
+ return this.page.url();
+ }
+
+ /**
+ * Get page title
+ */
+ async getTitle(): Promise {
+ return await this.page.title();
+ }
+
+ /**
+ * Reload the page
+ */
+ async reload(): Promise {
+ logger.debug('Reloading page');
+ await this.page.reload();
+ logger.debug('Page reloaded');
+ }
+
+ /**
+ * Go back in browser history
+ */
+ async goBack(): Promise {
+ logger.debug('Going back in history');
+ await this.page.goBack();
+ logger.debug('Navigated back');
+ }
+
+ /**
+ * Go forward in browser history
+ */
+ async goForward(): Promise {
+ logger.debug('Going forward in history');
+ await this.page.goForward();
+ logger.debug('Navigated forward');
+ }
+
+ /**
+ * Execute JavaScript in the browser
+ */
+ async evaluate(script: string | Function, ...args: any[]): Promise {
+ logger.debug('Executing script in browser');
+ return await this.page.evaluate(script as any, ...args);
+ }
+
+ /**
+ * Get locator for advanced operations
+ */
+ getLocator(selector: string): Locator {
+ return this.page.locator(selector);
}
}
\ No newline at end of file
diff --git a/src/web/network-helper.ts b/src/web/network-helper.ts
new file mode 100644
index 0000000..99c9a4e
--- /dev/null
+++ b/src/web/network-helper.ts
@@ -0,0 +1,347 @@
+import { Page, Route, Request, Response } from '@playwright/test';
+import { logger } from '../utils/logger';
+
+export interface NetworkLog {
+ url: string;
+ method: string;
+ status?: number;
+ statusText?: string;
+ duration?: number;
+ requestHeaders?: Record;
+ responseHeaders?: Record;
+ resourceType?: string;
+ timestamp: string;
+}
+
+export interface MockResponse {
+ status?: number;
+ contentType?: string;
+ body?: string | Buffer | object;
+ headers?: Record;
+}
+
+export class NetworkHelper {
+ private networkLogs: NetworkLog[] = [];
+ private requestTimestamps: Map = new Map();
+
+ constructor(private page: Page) {}
+
+ /**
+ * Start capturing network logs
+ */
+ startCapturingLogs(): void {
+ logger.info('Starting network log capture');
+
+ // Capture requests
+ this.page.on('request', (request: Request) => {
+ const timestamp = Date.now();
+ this.requestTimestamps.set(request.url(), timestamp);
+
+ this.networkLogs.push({
+ url: request.url(),
+ method: request.method(),
+ requestHeaders: request.headers(),
+ resourceType: request.resourceType(),
+ timestamp: new Date(timestamp).toISOString()
+ });
+ });
+
+ // Capture responses
+ this.page.on('response', (response: Response) => {
+ const request = response.request();
+ const requestTime = this.requestTimestamps.get(request.url()) || Date.now();
+ const duration = Date.now() - requestTime;
+
+ const logIndex = this.networkLogs.findIndex(log =>
+ log.url === request.url() && !log.status
+ );
+
+ if (logIndex !== -1) {
+ this.networkLogs[logIndex] = {
+ ...this.networkLogs[logIndex],
+ status: response.status(),
+ statusText: response.statusText(),
+ duration,
+ responseHeaders: response.headers()
+ };
+ }
+
+ this.requestTimestamps.delete(request.url());
+ });
+
+ logger.info('Network log capture started');
+ }
+
+ /**
+ * Stop capturing network logs
+ */
+ stopCapturingLogs(): void {
+ this.page.removeAllListeners('request');
+ this.page.removeAllListeners('response');
+ logger.info('Network log capture stopped');
+ }
+
+ /**
+ * Get all captured network logs
+ */
+ getLogs(): NetworkLog[] {
+ return [...this.networkLogs];
+ }
+
+ /**
+ * Get logs filtered by URL pattern
+ */
+ getLogsByUrl(pattern: string | RegExp): NetworkLog[] {
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
+ return this.networkLogs.filter(log => regex.test(log.url));
+ }
+
+ /**
+ * Get logs filtered by status code
+ */
+ getLogsByStatus(status: number): NetworkLog[] {
+ return this.networkLogs.filter(log => log.status === status);
+ }
+
+ /**
+ * Get failed requests (4xx, 5xx)
+ */
+ getFailedRequests(): NetworkLog[] {
+ return this.networkLogs.filter(log => log.status && log.status >= 400);
+ }
+
+ /**
+ * Clear all captured logs
+ */
+ clearLogs(): void {
+ this.networkLogs = [];
+ this.requestTimestamps.clear();
+ logger.debug('Network logs cleared');
+ }
+
+ /**
+ * Mock API response
+ */
+ async mockApiResponse(urlPattern: string | RegExp, mockResponse: MockResponse): Promise {
+ logger.info('Setting up API mock', {
+ pattern: urlPattern.toString(),
+ status: mockResponse.status
+ });
+
+ await this.page.route(urlPattern, async (route: Route) => {
+ const response = {
+ status: mockResponse.status || 200,
+ contentType: mockResponse.contentType || 'application/json',
+ headers: mockResponse.headers || {},
+ body: this.formatResponseBody(mockResponse.body)
+ };
+
+ await route.fulfill(response);
+
+ logger.debug('API response mocked', {
+ url: route.request().url(),
+ status: response.status
+ });
+ });
+ }
+
+ /**
+ * Block requests matching pattern
+ */
+ async blockRequests(patterns: (string | RegExp)[]): Promise {
+ logger.info('Setting up request blocking', {
+ patterns: patterns.map(p => p.toString())
+ });
+
+ await this.page.route('**/*', (route: Route) => {
+ const url = route.request().url();
+ const shouldBlock = patterns.some(pattern => {
+ if (typeof pattern === 'string') {
+ return url.includes(pattern);
+ }
+ return pattern.test(url);
+ });
+
+ if (shouldBlock) {
+ logger.debug('Request blocked', { url });
+ route.abort();
+ } else {
+ route.continue();
+ }
+ });
+ }
+
+ /**
+ * Block specific resource types (images, stylesheets, etc.)
+ */
+ async blockResourceTypes(resourceTypes: string[]): Promise {
+ logger.info('Blocking resource types', { resourceTypes });
+
+ await this.page.route('**/*', (route: Route) => {
+ const request = route.request();
+ if (resourceTypes.includes(request.resourceType())) {
+ logger.debug('Resource blocked', {
+ url: request.url(),
+ type: request.resourceType()
+ });
+ route.abort();
+ } else {
+ route.continue();
+ }
+ });
+ }
+
+ /**
+ * Modify request headers
+ */
+ async modifyRequestHeaders(
+ urlPattern: string | RegExp,
+ headers: Record
+ ): Promise {
+ logger.info('Setting up request header modification', {
+ pattern: urlPattern.toString()
+ });
+
+ await this.page.route(urlPattern, (route: Route) => {
+ const request = route.request();
+ route.continue({
+ headers: {
+ ...request.headers(),
+ ...headers
+ }
+ });
+
+ logger.debug('Request headers modified', { url: request.url() });
+ });
+ }
+
+ /**
+ * Modify response
+ */
+ async modifyResponse(
+ urlPattern: string | RegExp,
+ modifier: (body: string) => string
+ ): Promise {
+ logger.info('Setting up response modification', {
+ pattern: urlPattern.toString()
+ });
+
+ await this.page.route(urlPattern, async (route: Route) => {
+ const response = await route.fetch();
+ const body = await response.text();
+ const modifiedBody = modifier(body);
+
+ await route.fulfill({
+ response,
+ body: modifiedBody
+ });
+
+ logger.debug('Response modified', { url: route.request().url() });
+ });
+ }
+
+ /**
+ * Wait for specific request
+ */
+ async waitForRequest(urlPattern: string | RegExp, timeout: number = 30000): Promise {
+ logger.debug('Waiting for request', { pattern: urlPattern.toString() });
+
+ const request = await this.page.waitForRequest(urlPattern, { timeout });
+
+ logger.debug('Request captured', { url: request.url() });
+ return request;
+ }
+
+ /**
+ * Wait for specific response
+ */
+ async waitForResponse(urlPattern: string | RegExp, timeout: number = 30000): Promise {
+ logger.debug('Waiting for response', { pattern: urlPattern.toString() });
+
+ const response = await this.page.waitForResponse(urlPattern, { timeout });
+
+ logger.debug('Response captured', {
+ url: response.url(),
+ status: response.status()
+ });
+ return response;
+ }
+
+ /**
+ * Simulate slow network
+ */
+ async simulateSlowNetwork(downloadThroughput: number = 500 * 1024): Promise {
+ logger.info('Simulating slow network', {
+ downloadThroughput: `${downloadThroughput / 1024}KB/s`
+ });
+
+ await this.page.route('**/*', async (route: Route) => {
+ const response = await route.fetch();
+ const body = await response.body();
+
+ // Simulate delay based on content size
+ const delay = (body.length / downloadThroughput) * 1000;
+ await new Promise(resolve => setTimeout(resolve, delay));
+
+ await route.fulfill({ response, body });
+ });
+ }
+
+ /**
+ * Simulate network offline
+ */
+ async goOffline(): Promise {
+ logger.info('Going offline');
+ await this.page.context().setOffline(true);
+ }
+
+ /**
+ * Restore network online
+ */
+ async goOnline(): Promise {
+ logger.info('Going online');
+ await this.page.context().setOffline(false);
+ }
+
+ /**
+ * Get performance metrics for specific URL
+ */
+ getPerformanceMetrics(urlPattern: string | RegExp): {
+ totalRequests: number;
+ averageDuration: number;
+ minDuration: number;
+ maxDuration: number;
+ } {
+ const logs = this.getLogsByUrl(urlPattern);
+ const durations = logs
+ .map(log => log.duration)
+ .filter((d): d is number => d !== undefined);
+
+ if (durations.length === 0) {
+ return { totalRequests: 0, averageDuration: 0, minDuration: 0, maxDuration: 0 };
+ }
+
+ return {
+ totalRequests: logs.length,
+ averageDuration: durations.reduce((a, b) => a + b, 0) / durations.length,
+ minDuration: Math.min(...durations),
+ maxDuration: Math.max(...durations)
+ };
+ }
+
+ /**
+ * Format response body for mocking
+ */
+ private formatResponseBody(body?: string | Buffer | object): string | Buffer {
+ if (!body) return '';
+ if (typeof body === 'string' || Buffer.isBuffer(body)) return body;
+ return JSON.stringify(body);
+ }
+
+ /**
+ * Export logs to file
+ */
+ exportLogs(): string {
+ return JSON.stringify(this.networkLogs, null, 2);
+ }
+}
\ No newline at end of file
diff --git a/test-results/screenshots/user_can_login_and_add_items_to_cart-1761823172828.png b/test-results/screenshots/user_can_login_and_add_items_to_cart-1761823172828.png
new file mode 100644
index 0000000..6f25345
Binary files /dev/null and b/test-results/screenshots/user_can_login_and_add_items_to_cart-1761823172828.png differ
diff --git a/test-results/traces/user_can_login_and_add_items_to_cart-1761823173077.zip b/test-results/traces/user_can_login_and_add_items_to_cart-1761823173077.zip
new file mode 100644
index 0000000..fd48054
Binary files /dev/null and b/test-results/traces/user_can_login_and_add_items_to_cart-1761823173077.zip differ
diff --git a/test-results/videos/0d1fea196be9af3f43c55d47e99ee33d.webm b/test-results/videos/0d1fea196be9af3f43c55d47e99ee33d.webm
new file mode 100644
index 0000000..e1ae0cf
Binary files /dev/null and b/test-results/videos/0d1fea196be9af3f43c55d47e99ee33d.webm differ
diff --git a/test-results/videos/0dc31e335c1e14733b7bb31328a59494.webm b/test-results/videos/0dc31e335c1e14733b7bb31328a59494.webm
new file mode 100644
index 0000000..9e4efc7
Binary files /dev/null and b/test-results/videos/0dc31e335c1e14733b7bb31328a59494.webm differ
diff --git a/test-results/videos/103a3e51fc14c8ae0e2cae31b3c87c78.webm b/test-results/videos/103a3e51fc14c8ae0e2cae31b3c87c78.webm
new file mode 100644
index 0000000..3910878
Binary files /dev/null and b/test-results/videos/103a3e51fc14c8ae0e2cae31b3c87c78.webm differ
diff --git a/test-results/videos/1dab076935f1795eb81560c04474cb13.webm b/test-results/videos/1dab076935f1795eb81560c04474cb13.webm
new file mode 100644
index 0000000..008385f
Binary files /dev/null and b/test-results/videos/1dab076935f1795eb81560c04474cb13.webm differ
diff --git a/test-results/videos/30fcffad1eebb35817937853e7bdb070.webm b/test-results/videos/30fcffad1eebb35817937853e7bdb070.webm
new file mode 100644
index 0000000..b754e5e
Binary files /dev/null and b/test-results/videos/30fcffad1eebb35817937853e7bdb070.webm differ
diff --git a/test-results/videos/6d238a500be0c54bc8b427ed07645301.webm b/test-results/videos/6d238a500be0c54bc8b427ed07645301.webm
new file mode 100644
index 0000000..6af652f
Binary files /dev/null and b/test-results/videos/6d238a500be0c54bc8b427ed07645301.webm differ
diff --git a/test-results/videos/760e02d0b906f76634c6d00f510a6792.webm b/test-results/videos/760e02d0b906f76634c6d00f510a6792.webm
new file mode 100644
index 0000000..88a8c18
Binary files /dev/null and b/test-results/videos/760e02d0b906f76634c6d00f510a6792.webm differ
diff --git a/test-results/videos/8362baab441648e433c1ac11e7b46c1d.webm b/test-results/videos/8362baab441648e433c1ac11e7b46c1d.webm
new file mode 100644
index 0000000..715649d
Binary files /dev/null and b/test-results/videos/8362baab441648e433c1ac11e7b46c1d.webm differ
diff --git a/test-results/videos/85190355a1bf7529e23a0e545e58d9a3.webm b/test-results/videos/85190355a1bf7529e23a0e545e58d9a3.webm
new file mode 100644
index 0000000..53ab034
Binary files /dev/null and b/test-results/videos/85190355a1bf7529e23a0e545e58d9a3.webm differ
diff --git a/test-results/videos/d42e4dbd4c811a539df594fe57381279.webm b/test-results/videos/d42e4dbd4c811a539df594fe57381279.webm
new file mode 100644
index 0000000..89d34bb
Binary files /dev/null and b/test-results/videos/d42e4dbd4c811a539df594fe57381279.webm differ
diff --git a/test-results/videos/d5a947a98334bcc7bb189994e1a142d6.webm b/test-results/videos/d5a947a98334bcc7bb189994e1a142d6.webm
new file mode 100644
index 0000000..3a46bfb
Binary files /dev/null and b/test-results/videos/d5a947a98334bcc7bb189994e1a142d6.webm differ
diff --git a/test-results/videos/dcd775c0663f76cf89fb2295480aba4a.webm b/test-results/videos/dcd775c0663f76cf89fb2295480aba4a.webm
new file mode 100644
index 0000000..5e4890b
Binary files /dev/null and b/test-results/videos/dcd775c0663f76cf89fb2295480aba4a.webm differ
diff --git a/test-results/videos/ea755fb4d828c723122387e486c8fe03.webm b/test-results/videos/ea755fb4d828c723122387e486c8fe03.webm
new file mode 100644
index 0000000..9d67b71
Binary files /dev/null and b/test-results/videos/ea755fb4d828c723122387e486c8fe03.webm differ
diff --git a/test-results/videos/eb2465c6170fb6d32cf80ddf75e41f93.webm b/test-results/videos/eb2465c6170fb6d32cf80ddf75e41f93.webm
new file mode 100644
index 0000000..13e567d
Binary files /dev/null and b/test-results/videos/eb2465c6170fb6d32cf80ddf75e41f93.webm differ
diff --git a/tests/data/data-managers.ts b/tests/data/data-managers.ts
new file mode 100644
index 0000000..4707b19
--- /dev/null
+++ b/tests/data/data-managers.ts
@@ -0,0 +1,330 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { logger } from '../utils/logger';
+
+export interface UserCredentials {
+ username: string;
+ password: string;
+ role?: string;
+}
+
+export interface CheckoutInfo {
+ firstName: string;
+ lastName: string;
+ postalCode: string;
+}
+
+/**
+ * Centralized test data management
+ */
+export class TestDataManager {
+ private static instance: TestDataManager;
+ private dataCache: Map = new Map();
+
+ private constructor() {
+ logger.info('TestDataManager initialized');
+ }
+
+ static getInstance(): TestDataManager {
+ if (!TestDataManager.instance) {
+ TestDataManager.instance = new TestDataManager();
+ }
+ return TestDataManager.instance;
+ }
+
+ /**
+ * Load data from JSON file
+ */
+ loadFromFile(fileName: string): T {
+ const cacheKey = `file:${fileName}`;
+
+ if (this.dataCache.has(cacheKey)) {
+ logger.debug('Loading data from cache', { fileName });
+ return this.dataCache.get(cacheKey);
+ }
+
+ try {
+ const filePath = path.join(__dirname, fileName);
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
+ const data = JSON.parse(fileContent);
+
+ this.dataCache.set(cacheKey, data);
+ logger.info('Test data loaded from file', { fileName });
+
+ return data;
+ } catch (error) {
+ logger.error('Failed to load test data', { fileName, error });
+ throw error;
+ }
+ }
+
+ /**
+ * Save data to JSON file
+ */
+ saveToFile(fileName: string, data: any): void {
+ try {
+ const filePath = path.join(__dirname, fileName);
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
+
+ const cacheKey = `file:${fileName}`;
+ this.dataCache.set(cacheKey, data);
+
+ logger.info('Test data saved to file', { fileName });
+ } catch (error) {
+ logger.error('Failed to save test data', { fileName, error });
+ throw error;
+ }
+ }
+
+ /**
+ * Get random item from array
+ */
+ getRandomItem(array: T[]): T {
+ const index = Math.floor(Math.random() * array.length);
+ return array[index];
+ }
+
+ /**
+ * Clear cache
+ */
+ clearCache(): void {
+ this.dataCache.clear();
+ logger.info('Test data cache cleared');
+ }
+}
+
+/**
+ * Static test data repository
+ */
+export const TestData = {
+ users: {
+ standard: {
+ username: 'standard_user',
+ password: 'secret_sauce',
+ role: 'standard'
+ } as UserCredentials,
+ locked: {
+ username: 'locked_out_user',
+ password: 'secret_sauce',
+ role: 'locked'
+ } as UserCredentials,
+ problem: {
+ username: 'problem_user',
+ password: 'secret_sauce',
+ role: 'problem'
+ } as UserCredentials,
+ performance: {
+ username: 'performance_glitch_user',
+ password: 'secret_sauce',
+ role: 'performance'
+ } as UserCredentials,
+ invalid: {
+ username: 'invalid_user',
+ password: 'invalid_password',
+ role: 'invalid'
+ } as UserCredentials
+ },
+
+ products: {
+ backpack: 'Sauce Labs Backpack',
+ bikeLight: 'Sauce Labs Bike Light',
+ boltTShirt: 'Sauce Labs Bolt T-Shirt',
+ fleeceJacket: 'Sauce Labs Fleece Jacket',
+ onesie: 'Sauce Labs Onesie',
+ redTShirt: 'Test.allTheThings() T-Shirt (Red)'
+ },
+
+ checkout: {
+ valid: {
+ firstName: 'John',
+ lastName: 'Doe',
+ postalCode: '12345'
+ } as CheckoutInfo,
+ minimal: {
+ firstName: 'A',
+ lastName: 'B',
+ postalCode: '1'
+ } as CheckoutInfo,
+ international: {
+ firstName: 'JosƩ',
+ lastName: 'GarcĆa',
+ postalCode: 'SW1A 1AA'
+ } as CheckoutInfo
+ },
+
+ urls: {
+ base: 'https://www.saucedemo.com/',
+ inventory: 'https://www.saucedemo.com/inventory.html',
+ cart: 'https://www.saucedemo.com/cart.html',
+ checkout: 'https://www.saucedemo.com/checkout-step-one.html',
+ checkoutOverview: 'https://www.saucedemo.com/checkout-step-two.html',
+ checkoutComplete: 'https://www.saucedemo.com/checkout-complete.html'
+ },
+
+ errorMessages: {
+ lockedUser: 'Epic sadface: Sorry, this user has been locked out.',
+ invalidCredentials: 'Epic sadface: Username and password do not match any user in this service',
+ emptyUsername: 'Epic sadface: Username is required',
+ emptyPassword: 'Epic sadface: Password is required',
+ firstNameRequired: 'Error: First Name is required',
+ lastNameRequired: 'Error: Last Name is required',
+ postalCodeRequired: 'Error: Postal Code is required'
+ }
+};
+
+/**
+ * Data builder for dynamic test data generation
+ */
+export class DataBuilder {
+ /**
+ * Generate random string
+ */
+ static randomString(length: number = 10): string {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ let result = '';
+ for (let i = 0; i < length; i++) {
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ return result;
+ }
+
+ /**
+ * Generate random email
+ */
+ static randomEmail(): string {
+ return `test_${this.randomString(8)}@example.com`;
+ }
+
+ /**
+ * Generate random number in range
+ */
+ static randomNumber(min: number, max: number): number {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+ }
+
+ /**
+ * Generate random username
+ */
+ static randomUsername(): string {
+ return `user_${this.randomString(8)}`;
+ }
+
+ /**
+ * Generate random password
+ */
+ static randomPassword(length: number = 12): string {
+ const lowercase = 'abcdefghijklmnopqrstuvwxyz';
+ const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ const numbers = '0123456789';
+ const symbols = '!@#$%^&*';
+ const all = lowercase + uppercase + numbers + symbols;
+
+ let password = '';
+ password += lowercase[Math.floor(Math.random() * lowercase.length)];
+ password += uppercase[Math.floor(Math.random() * uppercase.length)];
+ password += numbers[Math.floor(Math.random() * numbers.length)];
+ password += symbols[Math.floor(Math.random() * symbols.length)];
+
+ for (let i = 4; i < length; i++) {
+ password += all[Math.floor(Math.random() * all.length)];
+ }
+
+ return password.split('').sort(() => Math.random() - 0.5).join('');
+ }
+
+ /**
+ * Generate checkout information
+ */
+ static generateCheckoutInfo(): CheckoutInfo {
+ const firstNames = ['John', 'Jane', 'Michael', 'Sarah', 'David', 'Emma'];
+ const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia'];
+
+ return {
+ firstName: firstNames[Math.floor(Math.random() * firstNames.length)],
+ lastName: lastNames[Math.floor(Math.random() * lastNames.length)],
+ postalCode: this.randomNumber(10000, 99999).toString()
+ };
+ }
+
+ /**
+ * Generate user credentials
+ */
+ static generateUserCredentials(): UserCredentials {
+ return {
+ username: this.randomUsername(),
+ password: this.randomPassword(),
+ role: 'generated'
+ };
+ }
+
+ /**
+ * Get current timestamp
+ */
+ static timestamp(): string {
+ return new Date().toISOString();
+ }
+
+ /**
+ * Get date string in format YYYY-MM-DD
+ */
+ static dateString(): string {
+ return new Date().toISOString().split('T')[0];
+ }
+
+ /**
+ * Get future date
+ */
+ static futureDate(daysFromNow: number): string {
+ const date = new Date();
+ date.setDate(date.getDate() + daysFromNow);
+ return date.toISOString().split('T')[0];
+ }
+
+ /**
+ * Get past date
+ */
+ static pastDate(daysAgo: number): string {
+ const date = new Date();
+ date.setDate(date.getDate() - daysAgo);
+ return date.toISOString().split('T')[0];
+ }
+}
+
+/**
+ * Environment-specific data loader
+ */
+export class EnvironmentDataLoader {
+ private static envData: Map = new Map();
+
+ /**
+ * Load environment-specific data
+ */
+ static load(environment: string): any {
+ if (this.envData.has(environment)) {
+ return this.envData.get(environment);
+ }
+
+ try {
+ const envFile = `${environment}.json`;
+ const manager = TestDataManager.getInstance();
+ const data = manager.loadFromFile(envFile);
+
+ this.envData.set(environment, data);
+ logger.info('Environment data loaded', { environment });
+
+ return data;
+ } catch (error) {
+ logger.warn('Environment data file not found, using defaults', { environment });
+ return {};
+ }
+ }
+
+ /**
+ * Get value by key for current environment
+ */
+ static get(key: string, environment?: string): any {
+ const env = environment || process.env.TEST_ENV || 'dev';
+ const data = this.load(env);
+ return data[key];
+ }
+}
\ No newline at end of file
diff --git a/tests/features/advance-testing.feature b/tests/features/advance-testing.feature
new file mode 100644
index 0000000..6375f2e
--- /dev/null
+++ b/tests/features/advance-testing.feature
@@ -0,0 +1,122 @@
+@advanced
+Feature: Advanced Testing Features
+ As a QA engineer
+ I want to perform visual, performance, accessibility, and mobile testing
+ So that I can ensure high quality standards
+
+ @visual @regression
+ Scenario: Visual regression testing
+ Given I am on the Sauce Demo login page
+ When I login with standard user credentials
+ Then I should see the products page
+ When I compare the page visually as "products-page"
+ Then all visual comparisons should pass
+
+ @visual
+ Scenario: Visual testing with masked elements
+ Given I am on the products page
+ When I compare the page with masked elements ".inventory_item_price,.shopping_cart_badge" as "products-masked"
+ Then all visual comparisons should pass
+
+ @visual
+ Scenario: Element visual comparison
+ Given I am on the products page
+ When I compare element ".inventory_item:first-child" visually as "first-product"
+ Then all visual comparisons should pass
+
+ @performance @smoke
+ Scenario: Page load performance
+ Given I am on the Sauce Demo login page
+ When I measure page load performance
+ Then the page should load within 3000ms
+ And the First Contentful Paint should be within 1800ms
+
+ @performance
+ Scenario: Performance budget validation
+ Given I am on the products page
+ When I measure page load performance
+ And I check the performance budget
+ And I generate a performance report
+ Then the performance score should be at least 70
+
+ @accessibility @wcag
+ Scenario: WCAG 2.1 AA Compliance
+ Given I am on the Sauce Demo login page
+ When I run an accessibility scan
+ Then there should be no critical accessibility violations
+ And the page should be WCAG "AA" compliant
+
+ @accessibility
+ Scenario: Comprehensive accessibility checks
+ Given I am on the products page
+ When I run an accessibility scan
+ And I check color contrast
+ And I check form labels
+ And I check image alt text
+ Then the accessibility score should be at least 85
+
+ @mobile @responsive
+ Scenario: Mobile device testing
+ Given I am using a "iPhone_13" device
+ And I am on the Sauce Demo login page
+ When I login with standard user credentials
+ Then I should see the products page
+
+ @mobile
+ Scenario: Responsive design testing
+ Given I am on the products page
+ When I test responsive breakpoints
+ Then the page should render correctly at all breakpoints
+
+ @mobile
+ Scenario: Device rotation testing
+ Given I am using a "iPad" device
+ And I am on the products page
+ When I rotate the device
+ Then the page should adapt to landscape orientation
+
+ @mobile @network
+ Scenario: Mobile network conditions
+ Given I am using a "iPhone_13" device
+ When I emulate "slow-3g" network
+ And I am on the Sauce Demo login page
+ Then the page should load successfully
+
+ @mobile
+ Scenario: Touch interactions
+ Given I am using a "iPhone_13" device
+ And I am on the products page
+ When I tap on ".inventory_item:first-child button"
+ Then the item should be added to cart
+
+ @quality @comprehensive
+ Scenario: Complete quality assessment
+ Given I am on the Sauce Demo login page
+ When I login with standard user credentials
+ And I run a complete quality check
+ Then all quality metrics should pass
+
+ @visual @responsive
+ Scenario: Visual testing across devices
+ Given I am on the products page
+ When I set the viewport to 375x667
+ And I compare the page visually as "mobile-products"
+ And I set the viewport to 1920x1080
+ And I compare the page visually as "desktop-products"
+ Then all visual comparisons should pass
+
+ @performance @mobile
+ Scenario: Mobile performance testing
+ Given I am using a "Pixel_5" device
+ When I emulate "4g" network
+ And I am on the products page
+ And I measure page load performance
+ Then the page should load within 5000ms
+
+ @accessibility @mobile
+ Scenario: Mobile accessibility
+ Given I am using a "iPhone_SE" device
+ And I am on the products page
+ When I run an accessibility scan
+ Then there should be no critical accessibility violations
+ And the accessibility score should be at least 80
\ No newline at end of file
diff --git a/tests/features/api-example.feature b/tests/features/api-example.feature
new file mode 100644
index 0000000..543fd67
--- /dev/null
+++ b/tests/features/api-example.feature
@@ -0,0 +1,48 @@
+@api
+Feature: API Testing Examples
+ As a QA engineer
+ I want to test APIs
+ So that I can verify backend functionality
+
+ @api @smoke
+ Scenario: GET request returns valid data
+ Given I have a valid API authentication token
+ When I make a GET request to "/api/users/1"
+ Then the API response status should be 200
+ And the API response should be valid JSON
+ And the API response should contain:
+ | id | 1 |
+ | name | John Doe |
+ | email | john@example.com |
+
+ @api
+ Scenario: POST request creates new resource
+ Given I have a valid API authentication token
+ When I make a POST request to "/api/users" with:
+ | name | Jane Smith |
+ | email | jane@example.com |
+ | role | admin |
+ Then the API response status should be 201
+ And the API response should be valid JSON
+
+ @network
+ Scenario: Verify network requests during UI interaction
+ Given I am on the Sauce Demo login page
+ When I start capturing network logs
+ And I login with standard user credentials
+ Then I should see a request to "inventory"
+ And the request to "inventory" should have status 200
+
+ @mock
+ Scenario: Mock API response for testing
+ Given I mock the API response for "/api/analytics"
+ And I am on the products page
+ When I add "Sauce Labs Backpack" to cart
+ Then the mocked analytics should be called
+
+ @block
+ Scenario: Block third-party requests
+ Given I block requests to "analytics.google.com"
+ And I block requests to "fonts.googleapis.com"
+ When I am on the Sauce Demo login page
+ Then the page should load without analytics
\ No newline at end of file
diff --git a/tests/pages/cart.page.ts b/tests/pages/cart.page.ts
index a03c63d..b5a9fbd 100644
--- a/tests/pages/cart.page.ts
+++ b/tests/pages/cart.page.ts
@@ -78,7 +78,7 @@ export class CartPage {
/**
* Remove a specific item from cart by product name
- */
+ */
async removeItem(productName: string): Promise {
const selector = `//div[text()="${productName}"]/ancestor::div[@class="cart_item"]//button[contains(@id, "remove")]`;
await this.actions.click(selector);
diff --git a/tests/steps/advanced.steps.ts b/tests/steps/advanced.steps.ts
new file mode 100644
index 0000000..19aea64
--- /dev/null
+++ b/tests/steps/advanced.steps.ts
@@ -0,0 +1,297 @@
+import { Given, When, Then } from '@cucumber/cucumber';
+import { expect } from '@playwright/test';
+import { TestWorld } from '../support/world';
+
+// ==================== Visual Testing Steps ====================
+
+When('I compare the page visually as {string}', async function(this: TestWorld, name: string) {
+ this.scenarioLogger.step(`Comparing page visual: ${name}`);
+ await this.visualTesting.compareFullPage(name);
+ this.scenarioLogger.info(`Visual comparison completed: ${name}`);
+});
+
+When('I compare element {string} visually as {string}', async function(this: TestWorld, selector: string, name: string) {
+ this.scenarioLogger.step(`Comparing element ${selector} visually: ${name}`);
+ await this.visualTesting.compareElement(selector, name);
+ this.scenarioLogger.info(`Element visual comparison completed`);
+});
+
+When('I compare the viewport visually as {string}', async function(this: TestWorld, name: string) {
+ this.scenarioLogger.step(`Comparing viewport visual: ${name}`);
+ await this.visualTesting.compareViewport(name);
+ this.scenarioLogger.info(`Viewport visual comparison completed`);
+});
+
+When('I compare the page with masked elements {string} as {string}', async function(this: TestWorld, selectors: string, name: string) {
+ this.scenarioLogger.step(`Comparing with masked elements: ${name}`);
+ const maskSelectors = selectors.split(',').map(s => s.trim());
+ await this.visualTesting.compareWithMask(name, maskSelectors);
+ this.scenarioLogger.info(`Visual comparison with mask completed`);
+});
+
+Then('all visual comparisons should pass', async function(this: TestWorld) {
+ this.scenarioLogger.step('Verifying all visual comparisons passed');
+ const summary = this.visualTesting.getSummary();
+
+ if (summary.failed > 0) {
+ this.scenarioLogger.error(`Visual tests failed`, summary);
+ throw new Error(`${summary.failed} out of ${summary.total} visual tests failed`);
+ }
+
+ this.scenarioLogger.info('All visual comparisons passed', summary);
+});
+
+// ==================== Performance Testing Steps ====================
+
+When('I measure page load performance', async function(this: TestWorld) {
+ this.scenarioLogger.step('Measuring page load performance');
+ const metrics = await this.performanceHelper.measurePageLoad();
+ this.scenarioLogger.info('Performance metrics captured', {
+ domContentLoaded: `${metrics.domContentLoaded.toFixed(2)}ms`,
+ loadComplete: `${metrics.loadComplete.toFixed(2)}ms`,
+ firstContentfulPaint: `${metrics.firstContentfulPaint.toFixed(2)}ms`
+ });
+});
+
+Then('the page should load within {int}ms', async function(this: TestWorld, maxTime: number) {
+ this.scenarioLogger.step(`Verifying page loads within ${maxTime}ms`);
+ const metrics = await this.performanceHelper.measurePageLoad();
+
+ expect(metrics.loadComplete).toBeLessThan(maxTime);
+ this.scenarioLogger.info(`Page load time verified: ${metrics.loadComplete.toFixed(2)}ms`);
+});
+
+Then('the First Contentful Paint should be within {int}ms', async function(this: TestWorld, maxTime: number) {
+ this.scenarioLogger.step(`Verifying FCP within ${maxTime}ms`);
+ const metrics = await this.performanceHelper.measurePageLoad();
+
+ expect(metrics.firstContentfulPaint).toBeLessThan(maxTime);
+ this.scenarioLogger.info(`FCP verified: ${metrics.firstContentfulPaint.toFixed(2)}ms`);
+});
+
+When('I check the performance budget', async function(this: TestWorld) {
+ this.scenarioLogger.step('Checking performance budget');
+ const violations = await this.performanceHelper.checkBudget({
+ domContentLoaded: 2000,
+ loadComplete: 3000,
+ firstContentfulPaint: 1800
+ });
+
+ if (violations.length > 0) {
+ this.scenarioLogger.warn('Performance budget violations', { count: violations.length });
+ } else {
+ this.scenarioLogger.info('Performance budget met');
+ }
+});
+
+When('I generate a performance report', async function(this: TestWorld) {
+ this.scenarioLogger.step('Generating performance report');
+ const report = await this.performanceHelper.generateReport();
+ this.scenarioLogger.info('Performance report generated', {
+ score: report.score,
+ violations: report.budgetViolations.length
+ });
+});
+
+Then('the performance score should be at least {int}', async function(this: TestWorld, minScore: number) {
+ this.scenarioLogger.step(`Verifying performance score >= ${minScore}`);
+ const report = await this.performanceHelper.generateReport();
+
+ expect(report.score).toBeGreaterThanOrEqual(minScore);
+ this.scenarioLogger.info(`Performance score verified: ${report.score}`);
+});
+
+// ==================== Accessibility Testing Steps ====================
+
+When('I run an accessibility scan', async function(this: TestWorld) {
+ this.scenarioLogger.step('Running accessibility scan');
+ const violations = await this.accessibilityHelper.scanPage();
+ this.scenarioLogger.info('Accessibility scan completed', {
+ violations: violations.length,
+ critical: violations.filter(v => v.impact === 'critical').length
+ });
+});
+
+Then('there should be no critical accessibility violations', async function(this: TestWorld) {
+ this.scenarioLogger.step('Checking for critical accessibility violations');
+ await this.accessibilityHelper.assertNoCriticalViolations();
+ this.scenarioLogger.info('No critical accessibility violations found');
+});
+
+Then('the page should be WCAG {string} compliant', async function(this: TestWorld, level: string) {
+ this.scenarioLogger.step(`Checking WCAG ${level} compliance`);
+ await this.accessibilityHelper.assertWCAGCompliance(level as 'A' | 'AA' | 'AAA');
+ this.scenarioLogger.info(`WCAG ${level} compliance verified`);
+});
+
+When('I check color contrast', async function(this: TestWorld) {
+ this.scenarioLogger.step('Checking color contrast');
+ const violations = await this.accessibilityHelper.checkColorContrast();
+
+ if (violations.length > 0) {
+ this.scenarioLogger.warn('Color contrast violations found', { count: violations.length });
+ } else {
+ this.scenarioLogger.info('All color contrasts meet standards');
+ }
+});
+
+When('I check form labels', async function(this: TestWorld) {
+ this.scenarioLogger.step('Checking form labels');
+ const violations = await this.accessibilityHelper.checkFormLabels();
+
+ if (violations.length > 0) {
+ this.scenarioLogger.warn('Form label violations found', { count: violations.length });
+ } else {
+ this.scenarioLogger.info('All forms have proper labels');
+ }
+});
+
+When('I check image alt text', async function(this: TestWorld) {
+ this.scenarioLogger.step('Checking image alt text');
+ const violations = await this.accessibilityHelper.checkImageAltText();
+
+ if (violations.length > 0) {
+ this.scenarioLogger.warn('Image alt text violations found', { count: violations.length });
+ } else {
+ this.scenarioLogger.info('All images have proper alt text');
+ }
+});
+
+Then('the accessibility score should be at least {int}', async function(this: TestWorld, minScore: number) {
+ this.scenarioLogger.step(`Verifying accessibility score >= ${minScore}`);
+ await this.accessibilityHelper.scanPage();
+ const score = this.accessibilityHelper.calculateScore();
+
+ expect(score).toBeGreaterThanOrEqual(minScore);
+ this.scenarioLogger.info(`Accessibility score verified: ${score}`);
+});
+
+// ==================== Mobile Testing Steps ====================
+
+Given('I am using a {string} device', async function(this: TestWorld, deviceName: string) {
+ this.scenarioLogger.step(`Setting up device: ${deviceName}`);
+
+ if (!this.mobileHelper || !this.browser) {
+ throw new Error('Mobile helper not available');
+ }
+
+ await this.mobileHelper.emulateDevice(this.page, deviceName as any);
+ this.scenarioLogger.info(`Device emulation applied: ${deviceName}`);
+});
+
+Given('I set the viewport to {int}x{int}', async function(this: TestWorld, width: number, height: number) {
+ this.scenarioLogger.step(`Setting viewport to ${width}x${height}`);
+
+ if (!this.mobileHelper) {
+ throw new Error('Mobile helper not available');
+ }
+
+ await this.mobileHelper.setViewport(this.page, width, height);
+ this.scenarioLogger.info(`Viewport set to ${width}x${height}`);
+});
+
+When('I rotate the device', async function(this: TestWorld) {
+ this.scenarioLogger.step('Rotating device');
+
+ if (!this.mobileHelper) {
+ throw new Error('Mobile helper not available');
+ }
+
+ await this.mobileHelper.rotate(this.page);
+ this.scenarioLogger.info('Device rotated');
+});
+
+When('I tap on {string}', async function(this: TestWorld, selector: string) {
+ this.scenarioLogger.step(`Tapping on ${selector}`);
+
+ if (!this.mobileHelper) {
+ throw new Error('Mobile helper not available');
+ }
+
+ await this.mobileHelper.tap(this.page, selector);
+ this.scenarioLogger.info(`Tapped on ${selector}`);
+});
+
+When('I set geolocation to {float}, {float}', async function(this: TestWorld, latitude: number, longitude: number) {
+ this.scenarioLogger.step(`Setting geolocation to ${latitude}, ${longitude}`);
+
+ if (!this.mobileHelper) {
+ throw new Error('Mobile helper not available');
+ }
+
+ await this.mobileHelper.setGeolocation(this.page, latitude, longitude);
+ this.scenarioLogger.info('Geolocation set');
+});
+
+When('I test responsive breakpoints', async function(this: TestWorld) {
+ this.scenarioLogger.step('Testing responsive breakpoints');
+
+ if (!this.mobileHelper) {
+ throw new Error('Mobile helper not available');
+ }
+
+ const breakpoints = Object.values((this.mobileHelper.constructor as typeof import('../../src/mobile/mobile-helper').MobileHelper).BREAKPOINTS);
+
+ for (const bp of breakpoints) {
+ await this.mobileHelper.setViewport(this.page, bp.width, bp.height);
+ await this.page.waitForTimeout(500);
+ this.scenarioLogger.debug(`Tested breakpoint: ${bp.name}`);
+ }
+
+ this.scenarioLogger.info('Responsive breakpoint testing completed');
+});
+
+When('I emulate {string} network', async function(this: TestWorld, networkType: string) {
+ this.scenarioLogger.step(`Emulating ${networkType} network`);
+
+ if (!this.mobileHelper) {
+ throw new Error('Mobile helper not available');
+ }
+
+ await this.mobileHelper.emulateNetworkConditions(
+ this.page,
+ networkType as 'offline' | 'slow-3g' | 'fast-3g' | '4g' | 'wifi'
+ );
+ this.scenarioLogger.info(`Network emulation applied: ${networkType}`);
+});
+
+// ==================== Combined Testing Steps ====================
+
+When('I run a complete quality check', async function(this: TestWorld) {
+ this.scenarioLogger.step('Running complete quality check');
+
+ // Performance
+ const perfReport = await this.performanceHelper.generateReport();
+ this.scenarioLogger.info('Performance check completed', { score: perfReport.score });
+
+ // Accessibility
+ const a11yReport = await this.accessibilityHelper.generateReport();
+ this.scenarioLogger.info('Accessibility check completed', { score: a11yReport.score });
+
+ // Visual
+ await this.visualTesting.compareFullPage('quality-check');
+ this.scenarioLogger.info('Visual check completed');
+
+ this.scenarioLogger.info('Complete quality check finished', {
+ performance: perfReport.score,
+ accessibility: a11yReport.score
+ });
+});
+
+Then('all quality metrics should pass', async function(this: TestWorld) {
+ this.scenarioLogger.step('Verifying all quality metrics');
+
+ // Check performance
+ const perfReport = await this.performanceHelper.generateReport();
+ expect(perfReport.score).toBeGreaterThanOrEqual(70);
+
+ // Check accessibility
+ const a11yReport = await this.accessibilityHelper.generateReport();
+ expect(a11yReport.score).toBeGreaterThanOrEqual(80);
+
+ // Check visual
+ const visualSummary = this.visualTesting.getSummary();
+ expect(visualSummary.failed).toBe(0);
+
+ this.scenarioLogger.info('All quality metrics passed');
+});
\ No newline at end of file
diff --git a/tests/steps/api.steps.ts b/tests/steps/api.steps.ts
new file mode 100644
index 0000000..1acf45d
--- /dev/null
+++ b/tests/steps/api.steps.ts
@@ -0,0 +1,153 @@
+import { Given, When, Then } from '@cucumber/cucumber';
+import { expect } from '@playwright/test';
+import { TestWorld } from '../support/world';
+
+/**
+ * Example API step definitions
+ * These can be used for API testing or API-based test data setup
+ */
+
+Given('I have a valid API authentication token', async function(this: TestWorld) {
+ this.scenarioLogger.step('Setting up API authentication');
+
+ // Example: Login via API to get token
+ const response = await this.apiClient.post('/api/auth/login', {
+ username: 'standard_user',
+ password: 'secret_sauce'
+ });
+
+ const body = await this.apiClient.getJsonBody(response);
+ const token = body.token;
+
+ this.apiClient.setAuthToken(token);
+ this.scenarioLogger.info('API authentication token set');
+});
+
+When('I make a GET request to {string}', async function(this: TestWorld, endpoint: string) {
+ this.scenarioLogger.step(`Making GET request to ${endpoint}`);
+
+ const response = await this.apiClient.get(endpoint);
+
+ // Store response in world for later assertions
+ (this as any).lastApiResponse = response;
+
+ this.scenarioLogger.info('GET request completed', {
+ status: response.status()
+ });
+});
+
+When('I make a POST request to {string} with:', async function(this: TestWorld, endpoint: string, dataTable) {
+ this.scenarioLogger.step(`Making POST request to ${endpoint}`);
+
+ const data = dataTable.rowsHash();
+
+ const response = await this.apiClient.post(endpoint, data);
+
+ (this as any).lastApiResponse = response;
+
+ this.scenarioLogger.info('POST request completed', {
+ status: response.status()
+ });
+});
+
+Then('the API response status should be {int}', async function(this: TestWorld, expectedStatus: number) {
+ this.scenarioLogger.step(`Verifying API response status is ${expectedStatus}`);
+
+ const response = (this as any).lastApiResponse;
+
+ if (!response) {
+ throw new Error('No API response found. Make sure to make a request first.');
+ }
+
+ await this.apiClient.assertStatus(response, expectedStatus);
+
+ this.scenarioLogger.info(`API response status verified: ${expectedStatus}`);
+});
+
+Then('the API response should contain:', async function(this: TestWorld, dataTable) {
+ this.scenarioLogger.step('Verifying API response contains expected data');
+
+ const response = (this as any).lastApiResponse;
+ const body = await this.apiClient.getJsonBody(response);
+
+ const expectedData = dataTable.rowsHash();
+
+ for (const [key, expectedValue] of Object.entries(expectedData)) {
+ const actualValue = body[key];
+ expect(actualValue).toBe(expectedValue);
+ this.scenarioLogger.debug(`Verified ${key}: ${actualValue}`);
+ }
+
+ this.scenarioLogger.info('API response data verified');
+});
+
+Then('the API response should be valid JSON', async function(this: TestWorld) {
+ this.scenarioLogger.step('Verifying API response is valid JSON');
+
+ const response = (this as any).lastApiResponse;
+
+ try {
+ const body = await this.apiClient.getJsonBody(response);
+ expect(body).toBeDefined();
+ this.scenarioLogger.info('API response is valid JSON');
+ } catch (error) {
+ this.scenarioLogger.error('API response is not valid JSON', { error });
+ throw error;
+ }
+});
+
+// Network interception examples
+
+Given('I mock the API response for {string}', async function(this: TestWorld, urlPattern: string) {
+ this.scenarioLogger.step(`Setting up API mock for ${urlPattern}`);
+
+ await this.networkHelper.mockApiResponse(urlPattern, {
+ status: 200,
+ contentType: 'application/json',
+ body: {
+ success: true,
+ message: 'Mocked response'
+ }
+ });
+
+ this.scenarioLogger.info('API mock configured');
+});
+
+Given('I block requests to {string}', async function(this: TestWorld, pattern: string) {
+ this.scenarioLogger.step(`Blocking requests to ${pattern}`);
+
+ await this.networkHelper.blockRequests([pattern]);
+
+ this.scenarioLogger.info('Request blocking configured');
+});
+
+When('I start capturing network logs', async function(this: TestWorld) {
+ this.scenarioLogger.step('Starting network log capture');
+
+ this.networkHelper.startCapturingLogs();
+
+ this.scenarioLogger.info('Network logging started');
+});
+
+Then('I should see a request to {string}', async function(this: TestWorld, urlPattern: string) {
+ this.scenarioLogger.step(`Verifying request was made to ${urlPattern}`);
+
+ const logs = this.networkHelper.getLogsByUrl(urlPattern);
+
+ expect(logs.length).toBeGreaterThan(0);
+
+ this.scenarioLogger.info(`Found ${logs.length} request(s) to ${urlPattern}`);
+});
+
+Then('the request to {string} should have status {int}', async function(this: TestWorld, urlPattern: string, expectedStatus: number) {
+ this.scenarioLogger.step(`Verifying request to ${urlPattern} returned status ${expectedStatus}`);
+
+ const logs = this.networkHelper.getLogsByUrl(urlPattern);
+
+ expect(logs.length).toBeGreaterThan(0);
+
+ const log = logs[0];
+ expect(log.status).toBe(expectedStatus);
+
+ this.scenarioLogger.info(`Request status verified: ${expectedStatus}`);
+});
\ No newline at end of file
diff --git a/tests/steps/shopping.steps.ts b/tests/steps/shopping.steps.ts
index 583f8b3..6d30c46 100644
--- a/tests/steps/shopping.steps.ts
+++ b/tests/steps/shopping.steps.ts
@@ -3,56 +3,81 @@ import { expect } from '@playwright/test';
import { TestWorld } from '../support/world';
Given('I am on the Sauce Demo login page', async function(this: TestWorld) {
+ this.scenarioLogger.step('Navigating to Sauce Demo login page');
await this.loginPage.navigateToLogin();
+ this.scenarioLogger.info('Successfully loaded login page');
});
When('I login with standard user credentials', async function(this: TestWorld) {
+ this.scenarioLogger.step('Logging in with standard user credentials');
await this.loginPage.login('standard_user', 'secret_sauce');
+ this.scenarioLogger.info('Login credentials submitted');
});
Then('I should see the products page', async function(this: TestWorld) {
+ this.scenarioLogger.step('Verifying products page is displayed');
const isOnProductsPage = await this.loginPage.isLoggedIn();
expect(isOnProductsPage).toBe(true);
+ this.scenarioLogger.info('Products page verified successfully');
});
When('I add {string} to cart', async function(this: TestWorld, productName: string) {
+ this.scenarioLogger.step(`Adding product "${productName}" to cart`);
await this.productsPage.addToCart(productName);
+ this.scenarioLogger.info(`Product "${productName}" added to cart`);
});
When('I click on cart icon', async function(this: TestWorld) {
+ this.scenarioLogger.step('Clicking on cart icon');
await this.productsPage.goToCart();
+ this.scenarioLogger.info('Navigated to cart page');
});
Then('I should see {string} in my cart', async function(this: TestWorld, productName: string) {
+ this.scenarioLogger.step(`Verifying "${productName}" is in the cart`);
const items = await this.cartPage.getCartItems();
- const item = items.find((item: { name: string; price: string; quantity: number }) => item.name === productName);
+ const item = items.find(item => item.name === productName);
expect(item).toBeDefined();
+ this.scenarioLogger.info(`Verified "${productName}" is present in cart`);
});
When('I navigate to cart', async function(this: TestWorld) {
+ this.scenarioLogger.step('Navigating to cart');
await this.productsPage.goToCart();
+ this.scenarioLogger.info('Opened cart page');
});
When('I proceed to checkout', async function(this: TestWorld) {
+ this.scenarioLogger.step('Proceeding to checkout');
await this.cartPage.proceedToCheckout();
+ this.scenarioLogger.info('Checkout page loaded');
});
When('I fill checkout information with following details:', async function(this: TestWorld, dataTable: { rawTable: string[][] }) {
const [_, data] = dataTable.rawTable;
const [firstName, lastName, postalCode] = data;
+
+ this.scenarioLogger.step(`Filling checkout information: ${firstName} ${lastName}, ${postalCode}`);
await this.cartPage.fillCheckoutInfo(firstName, lastName, postalCode);
+ this.scenarioLogger.info('Checkout information submitted successfully');
});
When('I complete the purchase', async function(this: TestWorld) {
+ this.scenarioLogger.step('Completing the purchase');
await this.cartPage.completeOrder();
+ this.scenarioLogger.info('Purchase completed');
});
Then('I should see the confirmation message', async function(this: TestWorld) {
+ this.scenarioLogger.step('Verifying order confirmation message');
const message = await this.cartPage.getConfirmationMessage();
expect(message).toContain('Thank you for your order!');
+ this.scenarioLogger.info(`Confirmation message verified: "${message}"`);
});
Then('I should see {int} items in the cart', async function(this: TestWorld, count: number) {
+ this.scenarioLogger.step(`Verifying cart contains ${count} item(s)`);
const actualCount = await this.productsPage.getCartItemsCount();
expect(actualCount).toBe(count);
+ this.scenarioLogger.info(`Cart count verified: ${actualCount} item(s)`);
});
\ No newline at end of file
diff --git a/tests/support/custom-reporter.ts b/tests/support/custom-reporter.ts
new file mode 100644
index 0000000..6c34dc8
--- /dev/null
+++ b/tests/support/custom-reporter.ts
@@ -0,0 +1,386 @@
+import { IFormatterOptions, Formatter } from '@cucumber/cucumber';
+import { EventEmitter } from 'events';
+import * as fs from 'fs';
+import * as path from 'path';
+import { logger, logTestSummary } from '../../src/utils/logger';
+
+interface TestStats {
+ total: number;
+ passed: number;
+ failed: number;
+ skipped: number;
+ pending: number;
+ startTime: number;
+ endTime?: number;
+ duration?: number;
+}
+
+interface ScenarioResult {
+ name: string;
+ status: string;
+ duration: number;
+ steps: number;
+ tags: string[];
+ error?: string;
+}
+
+/**
+ * Custom Cucumber formatter for enhanced reporting
+ */
+export class CustomReporter extends Formatter {
+ private stats: TestStats;
+ private scenarios: ScenarioResult[] = [];
+ private currentScenario?: {
+ name: string;
+ startTime: number;
+ steps: number;
+ tags: string[];
+ };
+
+ constructor(options: IFormatterOptions) {
+ super(options);
+
+ this.stats = {
+ total: 0,
+ passed: 0,
+ failed: 0,
+ skipped: 0,
+ pending: 0,
+ startTime: Date.now()
+ };
+
+ this.registerListeners(options.eventBroadcaster);
+ }
+
+ private registerListeners(eventBroadcaster: EventEmitter): void {
+ eventBroadcaster.on('envelope', (envelope: any) => {
+ if (envelope.testRunStarted) {
+ this.handleTestRunStarted();
+ } else if (envelope.testCase) {
+ this.handleTestCase(envelope.testCase);
+ } else if (envelope.testCaseStarted) {
+ this.handleTestCaseStarted(envelope.testCaseStarted);
+ } else if (envelope.testCaseFinished) {
+ this.handleTestCaseFinished(envelope.testCaseFinished);
+ } else if (envelope.testRunFinished) {
+ this.handleTestRunFinished();
+ }
+ });
+ }
+
+ private handleTestRunStarted(): void {
+ this.stats.startTime = Date.now();
+ logger.info('ā'.repeat(80));
+ logger.info('š Test Run Started');
+ logger.info('ā'.repeat(80));
+ }
+
+ private handleTestCase(testCase: any): void {
+ // Store test case information for later use
+ }
+
+ private handleTestCaseStarted(testCaseStarted: any): void {
+ this.stats.total++;
+
+ // Note: Actual scenario name and tags would need to be extracted
+ // from the test case mapping. This is simplified.
+ this.currentScenario = {
+ name: `Scenario ${this.stats.total}`,
+ startTime: Date.now(),
+ steps: 0,
+ tags: []
+ };
+ }
+
+ private handleTestCaseFinished(testCaseFinished: any): void {
+ if (!this.currentScenario) return;
+
+ const duration = Date.now() - this.currentScenario.startTime;
+ const status = this.getTestStatus(testCaseFinished);
+
+ // Update stats
+ switch (status) {
+ case 'passed':
+ this.stats.passed++;
+ break;
+ case 'failed':
+ this.stats.failed++;
+ break;
+ case 'skipped':
+ this.stats.skipped++;
+ break;
+ case 'pending':
+ this.stats.pending++;
+ break;
+ }
+
+ // Store scenario result
+ this.scenarios.push({
+ name: this.currentScenario.name,
+ status,
+ duration,
+ steps: this.currentScenario.steps,
+ tags: this.currentScenario.tags,
+ error: testCaseFinished.testCaseResult?.message
+ });
+
+ this.currentScenario = undefined;
+ }
+
+ private handleTestRunFinished(): void {
+ this.stats.endTime = Date.now();
+ this.stats.duration = this.stats.endTime - this.stats.startTime;
+
+ // Log summary
+ logTestSummary({
+ total: this.stats.total,
+ passed: this.stats.passed,
+ failed: this.stats.failed,
+ skipped: this.stats.skipped,
+ duration: this.stats.duration
+ });
+
+ // Generate enhanced report
+ this.generateEnhancedReport();
+ }
+
+ private getTestStatus(testCaseFinished: any): string {
+ const status = testCaseFinished.testCaseResult?.status;
+ return status?.toLowerCase() || 'unknown';
+ }
+
+ private generateEnhancedReport(): void {
+ const reportDir = path.join(process.cwd(), 'test-results', 'reports');
+
+ if (!fs.existsSync(reportDir)) {
+ fs.mkdirSync(reportDir, { recursive: true });
+ }
+
+ const report = {
+ summary: {
+ ...this.stats,
+ successRate: ((this.stats.passed / this.stats.total) * 100).toFixed(2) + '%',
+ environment: process.env.TEST_ENV || 'dev',
+ browser: process.env.BROWSER || 'chromium',
+ timestamp: new Date().toISOString()
+ },
+ scenarios: this.scenarios,
+ slowestScenarios: this.getTopSlowScenarios(5),
+ failedScenarios: this.scenarios.filter(s => s.status === 'failed')
+ };
+
+ // Save JSON report
+ const jsonPath = path.join(reportDir, 'enhanced-report.json');
+ fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
+ logger.info('Enhanced JSON report generated', { path: jsonPath });
+
+ // Generate simple HTML summary
+ const htmlPath = path.join(reportDir, 'summary.html');
+ fs.writeFileSync(htmlPath, this.generateHtmlSummary(report));
+ logger.info('HTML summary generated', { path: htmlPath });
+ }
+
+ private getTopSlowScenarios(count: number): ScenarioResult[] {
+ return [...this.scenarios]
+ .sort((a, b) => b.duration - a.duration)
+ .slice(0, count);
+ }
+
+ private generateHtmlSummary(report: any): string {
+ const passRate = report.summary.successRate;
+ const passColor = parseFloat(passRate) >= 80 ? 'green' :
+ parseFloat(passRate) >= 50 ? 'orange' : 'red';
+
+ return `
+
+
+
+
+ Test Execution Summary
+
+
+
+
+
š Test Execution Summary
+
+
+
${passRate}
+
Success Rate
+
+
+
+
+
Total Scenarios
+
${report.summary.total}
+
+
+
Passed
+
${report.summary.passed}
+
+
+
Failed
+
${report.summary.failed}
+
+
+
Duration
+
${(report.summary.duration / 1000).toFixed(2)}s
+
+
+
+ ${report.failedScenarios.length > 0 ? `
+
ā Failed Scenarios
+
+
+
+ Scenario
+ Duration
+ Error
+
+
+
+ ${report.failedScenarios.map((s: ScenarioResult) => `
+
+ ${s.name}
+ ${(s.duration / 1000).toFixed(2)}s
+
+ ${s.error ? s.error.substring(0, 100) + '...' : 'N/A'}
+
+
+ `).join('')}
+
+
+ ` : ''}
+
+
š Slowest Scenarios
+
+
+
+ Scenario
+ Status
+ Duration
+
+
+
+ ${report.slowestScenarios.map((s: ScenarioResult) => `
+
+ ${s.name}
+
+
+ ${s.status.toUpperCase()}
+
+
+ ${(s.duration / 1000).toFixed(2)}s
+
+ `).join('')}
+
+
+
+
+
+
+`;
+ }
+}
\ No newline at end of file
diff --git a/tests/support/hooks.ts b/tests/support/hooks.ts
index b1a5006..5b9f930 100644
--- a/tests/support/hooks.ts
+++ b/tests/support/hooks.ts
@@ -1,73 +1,242 @@
-import { After, AfterAll, Before, BeforeAll, setDefaultTimeout } from '@cucumber/cucumber';
-import { chromium, Browser, BrowserContext } from '@playwright/test';
+import { Before, After, BeforeAll, AfterAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
+import { Browser, chromium, firefox, webkit } from '@playwright/test';
import { CustomWorld } from './world';
+import { config, getBrowserLaunchOptions, getBrowserContextOptions } from '../../config/test.config';
+import { logger, ScenarioLogger } from '../../src/utils/logger';
+import path from 'path';
+import fs from 'fs';
-setDefaultTimeout(60 * 1000); // increase default hook/step timeout to 60s
+let browser: Browser;
+let scenarioLogger: ScenarioLogger;
-let browser: Browser | undefined;
-let context: BrowserContext | undefined;
+// Set default timeout from config
+setDefaultTimeout(config.timeout);
+
+/**
+ * Launch browser before all tests
+ */
+BeforeAll(async function() {
+ logger.info('='.repeat(60));
+ logger.info('Starting test suite execution');
+ logger.info('='.repeat(60));
-BeforeAll({ timeout: 60 * 1000 }, async () => {
- // Launch browser for this worker/process
- if (browser) return;
try {
- browser = await chromium.launch({
- headless: process.env.HEADLESS === 'true',
- args: ['--start-maximized']
- });
- } catch (err) {
- console.error('Failed to launch browser in BeforeAll:', err);
- throw err;
+ const launchOptions = getBrowserLaunchOptions();
+
+ // Select browser based on config
+ switch (config.browser) {
+ case 'firefox':
+ browser = await firefox.launch(launchOptions);
+ logger.info('Firefox browser launched');
+ break;
+ case 'webkit':
+ browser = await webkit.launch(launchOptions);
+ logger.info('WebKit browser launched');
+ break;
+ default:
+ browser = await chromium.launch(launchOptions);
+ logger.info('Chromium browser launched');
+ }
+ } catch (error) {
+ logger.error('Failed to launch browser', { error });
+ throw error;
}
});
-Before(async function (this: CustomWorld) {
- if (!browser) {
- throw new Error('Browser is not initialized. Ensure BeforeAll launched the browser.');
- }
-
+/**
+ * Setup before each scenario
+ */
+Before(async function(this: CustomWorld, { pickle, gherkinDocument }) {
+ const scenarioName = pickle.name;
+ scenarioLogger = new ScenarioLogger(scenarioName);
+
+ scenarioLogger.info('Starting scenario');
+
try {
- // Create new context and page for each scenario
- context = await browser.newContext({
- viewport: null,
- recordVideo: process.env.RECORD_VIDEO === 'true' ? {
- dir: 'test-results/videos'
- } : undefined
+ // Create browser context with configuration
+ const contextOptions = getBrowserContextOptions();
+ const context = await browser.newContext(contextOptions);
+
+ // Start tracing if enabled
+ if (config.trace.enabled) {
+ await context.tracing.start({
+ screenshots: true,
+ snapshots: true,
+ sources: true
+ });
+ scenarioLogger.debug('Tracing started');
+ }
+
+ // Create new page
+ const page = await context.newPage();
+
+ // Add console message listener
+ page.on('console', msg => {
+ scenarioLogger.debug(`Browser console [${msg.type()}]: ${msg.text()}`);
+ });
+
+ // Add error listener
+ page.on('pageerror', error => {
+ scenarioLogger.error('Page error occurred', { error: error.message });
+ });
+
+ // Add request failure listener (filter out non-critical failures)
+ page.on('requestfailed', request => {
+ const url = request.url();
+ const failure = request.failure()?.errorText;
+
+ // Ignore known third-party failures that don't affect tests
+ const ignoredPatterns = [
+ 'backtrace.io',
+ 'fonts.gstatic.com',
+ 'fonts.googleapis.com',
+ 'analytics',
+ 'google-analytics',
+ 'gtag',
+ 'doubleclick'
+ ];
+
+ const shouldIgnore = ignoredPatterns.some(pattern => url.includes(pattern));
+
+ if (!shouldIgnore) {
+ scenarioLogger.warn('ā ļø Request failed', {
+ url,
+ failure
+ });
+ } else {
+ // Log at debug level for ignored requests
+ scenarioLogger.debug('Third-party request failed (ignored)', {
+ url,
+ failure
+ });
+ }
});
- this.page = await context.newPage();
- this.browser = browser;
+ // Initialize World with page and logger
+ await this.initialize(page, scenarioLogger);
- // Initialize page objects
- await this.initialize(this.page);
- } catch (err) {
- console.error('Error in Before hook:', err);
- throw err;
+ scenarioLogger.info('Browser context and page initialized');
+
+ // Navigate to base URL if feature has @ui tag
+ const tags = pickle.tags.map(t => t.name);
+ if (tags.includes('@ui')) {
+ await page.goto(config.baseURL, { waitUntil: 'domcontentloaded' });
+ scenarioLogger.info('Navigated to base URL', { url: config.baseURL });
+ }
+
+ } catch (error) {
+ scenarioLogger.error('Failed to setup scenario', { error });
+ throw error;
}
});
-After(async function () {
- // Close context after each scenario. Guard and swallow errors to avoid crashing worker.
+/**
+ * Cleanup after each scenario
+ */
+After(async function(this: CustomWorld, { pickle, result }) {
+ const scenarioName = pickle.name;
+ const status = result?.status;
+
try {
- if (context) {
- await context.close();
+ // Handle failure - capture artifacts
+ if (status === Status.FAILED) {
+ scenarioLogger.error('Scenario failed');
+
+ // Capture screenshot
+ if (config.screenshot.enabled) {
+ const screenshotPath = path.join(
+ config.screenshot.dir,
+ `${sanitizeFileName(scenarioName)}-${Date.now()}.png`
+ );
+
+ // Ensure directory exists
+ fs.mkdirSync(config.screenshot.dir, { recursive: true });
+
+ const screenshot = await this.page.screenshot({
+ path: screenshotPath,
+ fullPage: true
+ });
+
+ // Attach to Cucumber report
+ this.attach(screenshot, 'image/png');
+ scenarioLogger.info('Screenshot captured', { path: screenshotPath });
+ }
+
+ // Capture page HTML
+ const html = await this.page.content();
+ this.attach(html, 'text/html');
+
+ // Capture browser logs
+ const logs = await this.page.evaluate(() => {
+ return (window as any).testLogs || [];
+ });
+ if (logs.length > 0) {
+ this.attach(JSON.stringify(logs, null, 2), 'application/json');
+ }
+
+ // Stop and save trace
+ if (config.trace.enabled) {
+ const tracePath = path.join(
+ config.trace.dir,
+ `${sanitizeFileName(scenarioName)}-${Date.now()}.zip`
+ );
+
+ fs.mkdirSync(config.trace.dir, { recursive: true });
+
+ await this.page.context().tracing.stop({ path: tracePath });
+ scenarioLogger.info('Trace saved', { path: tracePath });
+ }
+ } else if (status === Status.PASSED) {
+ scenarioLogger.info('Scenario passed');
+
+ // Stop trace without saving if passed (unless mode is 'on')
+ if (config.trace.enabled && config.trace.mode !== 'on') {
+ await this.page.context().tracing.stop();
+ } else if (config.trace.enabled && config.trace.mode === 'on') {
+ const tracePath = path.join(
+ config.trace.dir,
+ `${sanitizeFileName(scenarioName)}-${Date.now()}.zip`
+ );
+ fs.mkdirSync(config.trace.dir, { recursive: true });
+ await this.page.context().tracing.stop({ path: tracePath });
+ }
}
- } catch (err) {
- console.error('Error closing context in After hook:', err);
- } finally {
- context = undefined;
+
+ // Close page and context
+ await this.cleanup();
+ await this.page.context().close();
+ scenarioLogger.info('Browser context closed');
+
+ } catch (error) {
+ scenarioLogger.error('Error during cleanup', { error });
}
});
-AfterAll({ timeout: 60 * 1000 }, async () => {
- // Close browser at end of test run for this worker/process
+/**
+ * Close browser after all tests
+ */
+AfterAll(async function() {
try {
if (browser) {
await browser.close();
- browser = undefined;
+ logger.info('Browser closed');
}
- } catch (err) {
- console.error('Error closing browser in AfterAll hook:', err);
- // do not rethrow to avoid worker crash on teardown
+
+ logger.info('='.repeat(60));
+ logger.info('Test suite execution completed');
+ logger.info('='.repeat(60));
+ } catch (error) {
+ logger.error('Error closing browser', { error });
}
-});
\ No newline at end of file
+});
+
+/**
+ * Helper function to sanitize file names
+ */
+function sanitizeFileName(name: string): string {
+ return name
+ .replace(/[^a-z0-9]/gi, '_')
+ .replace(/_+/g, '_')
+ .toLowerCase()
+ .substring(0, 50);
+}
\ No newline at end of file
diff --git a/tests/support/world.ts b/tests/support/world.ts
index 1f9205d..443d280 100644
--- a/tests/support/world.ts
+++ b/tests/support/world.ts
@@ -1,39 +1,80 @@
import { World as CucumberWorld, setWorldConstructor, IWorldOptions } from '@cucumber/cucumber';
import { Browser, Page } from '@playwright/test';
import { WebActions } from '../../src/web/actions';
-import { LoginPage } from '../pages/login.page'; // ā
Import class, not object
-import { ProductsPage } from '../pages/products.page'; // ā
Import class, not object
-import { CartPage } from '../pages/cart.page'; // ā
Import class, not object
+import { LoginPage } from '../pages/login.page';
+import { ProductsPage } from '../pages/products.page';
+import { CartPage } from '../pages/cart.page';
+import { ScenarioLogger } from '../../src/utils/logger';
+import { ApiClient } from '../../src/api/api-client';
+import { NetworkHelper } from '../../src/web/network-helper';
+import { VisualTesting } from '../../src/visual/visual-testing';
+import { PerformanceHelper } from '../../src/performance/performance-helper';
+import { AccessibilityHelper } from '@/accessibility/accessibility-helper';
+import { MobileHelper } from '@/mobile/mobile-helper';
export interface TestWorld extends CucumberWorld {
browser?: Browser;
page: Page;
- loginPage: LoginPage; // ā
Use class type
- productsPage: ProductsPage; // ā
Use class type
- cartPage: CartPage; // ā
Use class type
+ loginPage: LoginPage;
+ productsPage: ProductsPage;
+ cartPage: CartPage;
webActions: WebActions;
+ scenarioLogger: ScenarioLogger;
+ apiClient: ApiClient;
+ networkHelper: NetworkHelper;
+ visualTesting: VisualTesting;
+ performanceHelper: PerformanceHelper;
+ accessibilityHelper: AccessibilityHelper;
+ mobileHelper: MobileHelper;
}
export class CustomWorld extends CucumberWorld implements TestWorld {
public browser?: Browser;
public page!: Page;
- public loginPage!: LoginPage; // ā
Use class type
- public productsPage!: ProductsPage; // ā
Use class type
- public cartPage!: CartPage; // ā
Use class type
+ public loginPage!: LoginPage;
+ public productsPage!: ProductsPage;
+ public cartPage!: CartPage;
public webActions!: WebActions;
+ public scenarioLogger!: ScenarioLogger;
+ public apiClient!: ApiClient;
+ public networkHelper!: NetworkHelper;
+ public visualTesting!: VisualTesting;
+ public performanceHelper!: PerformanceHelper;
+ public accessibilityHelper!: AccessibilityHelper;
+ public mobileHelper!: MobileHelper;
constructor(options: IWorldOptions) {
super(options);
}
- async initialize(page: Page): Promise {
+ async initialize(page: Page, scenarioLogger: ScenarioLogger): Promise {
this.page = page;
+ this.scenarioLogger = scenarioLogger;
this.webActions = new WebActions(this.page);
- // Initialize page objects - instantiate the classes
+ // Initialize page objects with page and actions
this.loginPage = new LoginPage(this.page, this.webActions);
this.productsPage = new ProductsPage(this.page, this.webActions);
this.cartPage = new CartPage(this.page, this.webActions);
+
+ // Initialize API client
+ this.apiClient = new ApiClient();
+ await this.apiClient.init();
+
+ // Initialize network helper
+ this.networkHelper = new NetworkHelper(this.page);
+ }
+
+ async cleanup(): Promise {
+ // Dispose API client
+ if (this.apiClient) {
+ await this.apiClient.dispose();
+ }
+
+ // Stop network logging if active
+ if (this.networkHelper) {
+ this.networkHelper.stopCapturingLogs();
+ }
}
}
diff --git a/tests/test-results/reports/cucumber-report.html b/tests/test-results/reports/cucumber-report.html
index 49f876c..7265a52 100644
--- a/tests/test-results/reports/cucumber-report.html
+++ b/tests/test-results/reports/cucumber-report.html
@@ -4,39 +4,35 @@
Cucumber
-
+
-
@@ -44,14 +40,11 @@
-