diff --git a/.github/SHARDING_WORKFLOWS.md b/.github/SHARDING_WORKFLOWS.md new file mode 100644 index 000000000..c4fde964a --- /dev/null +++ b/.github/SHARDING_WORKFLOWS.md @@ -0,0 +1,96 @@ +# Test Sharding Workflows + +This document explains the GitHub Actions workflows that demonstrate the new test sharding functionality in CodeceptJS. + +## Updated/Created Workflows + +### 1. `acceptance-tests.yml` (Updated) + +**Purpose**: Demonstrates sharding with acceptance tests across multiple browser configurations. + +**Key Features**: + +- Runs traditional docker-compose tests (for backward compatibility) +- Adds new sharded acceptance tests using CodeceptJS directly +- Tests across multiple browser configurations (Puppeteer, Playwright) +- Uses 2x2 matrix: 2 configs × 2 shards = 4 parallel jobs + +**Example Output**: + +``` +- Sharded Tests: codecept.Puppeteer.js (Shard 1/2) +- Sharded Tests: codecept.Puppeteer.js (Shard 2/2) +- Sharded Tests: codecept.Playwright.js (Shard 1/2) +- Sharded Tests: codecept.Playwright.js (Shard 2/2) +``` + +### 2. `sharding-demo.yml` (New) + +**Purpose**: Comprehensive demonstration of sharding features with larger test suite. + +**Key Features**: + +- Uses sandbox tests (2 main test files) for sharding demonstration +- Shows basic sharding with 2-way split (`1/2`, `2/2`) +- Demonstrates combination of `--shuffle` + `--shard` options +- Uses `DONT_FAIL_ON_EMPTY_RUN=true` to handle cases where some shards may be empty + +### 3. `test.yml` (Updated) + +**Purpose**: Clarifies which tests support sharding. + +**Changes**: + +- Added comment explaining that runner tests are mocha-based and don't support sharding +- Points to sharding-demo.yml for examples of CodeceptJS-based sharding + +## Sharding Commands Used + +### Basic Sharding + +```bash +npx codeceptjs run --config ./codecept.js --shard 1/2 +npx codeceptjs run --config ./codecept.js --shard 2/2 +``` + +### Combined with Other Options + +```bash +npx codeceptjs run --config ./codecept.js --shuffle --shard 1/2 --verbose +``` + +## Test Distribution + +The sharding algorithm distributes tests evenly: + +- **38 tests across 4 shards**: ~9-10 tests per shard +- **6 acceptance tests across 2 shards**: 3 tests per shard +- **Uneven splits handled gracefully**: Earlier shards get extra tests when needed + +## Benefits Demonstrated + +1. **Parallel Execution**: Tests run simultaneously across multiple CI workers +2. **No Manual Configuration**: Automatic test distribution without maintaining test lists +3. **Load Balancing**: Even distribution ensures balanced execution times +4. **Flexibility**: Works with any number of shards and test configurations +5. **Integration**: Compatible with existing CodeceptJS features (`--shuffle`, `--verbose`, etc.) + +## CI Matrix Integration + +The workflows show practical CI matrix usage: + +```yaml +strategy: + matrix: + config: ['codecept.Puppeteer.js', 'codecept.Playwright.js'] + shard: ['1/2', '2/2'] +``` + +This creates 4 parallel jobs: + +- Config A, Shard 1/2 +- Config A, Shard 2/2 +- Config B, Shard 1/2 +- Config B, Shard 2/2 + +Perfect for scaling test execution across multiple machines and configurations. diff --git a/.github/workflows/acceptance-tests.yml b/.github/workflows/acceptance-tests.yml index 9af54c7d9..e92699122 100644 --- a/.github/workflows/acceptance-tests.yml +++ b/.github/workflows/acceptance-tests.yml @@ -1,4 +1,4 @@ -name: Acceptance Tests using docker compose +name: Acceptance Tests on: push: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index de1a0fdf9..77df0d4a8 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -48,7 +48,9 @@ jobs: run: './bin/codecept.js run -c test/acceptance/codecept.Playwright.js --grep @Playwright --debug' - name: run chromium with restart==browser tests run: 'BROWSER_RESTART=browser ./bin/codecept.js run -c test/acceptance/codecept.Playwright.js --grep @Playwright --debug' - - name: run chromium with restart==session tests + - name: run chromium with restart==session tests on 2 workers split by pool + run: 'BROWSER_RESTART=session ./bin/codecept.js run-workers 2 -c test/acceptance/codecept.Playwright.js --grep @Playwright --debug --by=pool' + - name: run chromium with restart==session tests and run: 'BROWSER_RESTART=session ./bin/codecept.js run -c test/acceptance/codecept.Playwright.js --grep @Playwright --debug' - name: run firefox tests run: 'BROWSER=firefox node ./bin/codecept.js run -c test/acceptance/codecept.Playwright.js --grep @Playwright --debug' diff --git a/.github/workflows/sharding-demo.yml b/.github/workflows/sharding-demo.yml new file mode 100644 index 000000000..c2408a8f8 --- /dev/null +++ b/.github/workflows/sharding-demo.yml @@ -0,0 +1,39 @@ +name: Minimal Sharding Test + +on: + push: + branches: + - '3.x' + pull_request: + branches: + - '**' + +env: + CI: true + FORCE_COLOR: 1 + +jobs: + test-sharding: + runs-on: ubuntu-latest + name: 'Shard ${{ matrix.shard }}' + + strategy: + fail-fast: false + matrix: + shard: ['1/2', '2/2'] + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install --ignore-scripts + + - name: Run tests with sharding + run: npx codeceptjs run --config ./codecept.js --shard ${{ matrix.shard }} + working-directory: test/data/sandbox diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 585b33b29..f979e09fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,3 +48,5 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true - run: npm run test:runner + # Note: Runner tests are mocha-based, so sharding doesn't apply here. + # For CodeceptJS sharding examples, see sharding-demo.yml workflow. diff --git a/.gitignore b/.gitignore index fc1f70320..4afed9191 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ examples/selenoid-example/output test/data/app/db test/data/sandbox/steps.d.ts test/data/sandbox/configs/custom-reporter-plugin/output/result.json +test/data/sandbox/configs/html-reporter-plugin/output/ +output/ +test/runner/output/ testpullfilecache* .DS_Store package-lock.json diff --git a/Dockerfile b/Dockerfile index d637da4b5..a0f367919 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,12 +12,8 @@ RUN apt-get update && \ # Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others) # Note: this installs the necessary libs to make the bundled version of Chromium that Puppeteer # installs, work. -RUN apt-get update && apt-get install -y gnupg wget && \ - wget --quiet --output-document=- https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor > /etc/apt/trusted.gpg.d/google-archive.gpg && \ - echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \ - apt-get update && \ - apt-get install -y google-chrome-stable --no-install-recommends && \ - rm -rf /var/lib/apt/lists/* +# Skip Chrome installation for now as Playwright image already has browsers +RUN echo "Skipping Chrome installation - using Playwright browsers" # Add pptr user. @@ -31,17 +27,23 @@ RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ COPY . /codecept RUN chown -R pptruser:pptruser /codecept -RUN runuser -l pptruser -c 'npm i --loglevel=warn --prefix /codecept' +# Set environment variables to skip browser downloads during npm install +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_SKIP_DOWNLOAD=true +# Install as root to ensure proper bin links are created +RUN cd /codecept && npm install --loglevel=warn +# Fix ownership after install +RUN chown -R pptruser:pptruser /codecept RUN ln -s /codecept/bin/codecept.js /usr/local/bin/codeceptjs RUN mkdir /tests WORKDIR /tests -# Install puppeteer so it's available in the container. -RUN npm i puppeteer@$(npm view puppeteer version) && npx puppeteer browsers install chrome -RUN google-chrome --version +# Skip the redundant Puppeteer installation step since we're using Playwright browsers +# RUN npm i puppeteer@$(npm view puppeteer version) && npx puppeteer browsers install chrome +# RUN chromium-browser --version -# Install playwright browsers -RUN npx playwright install +# Skip the playwright browser installation step since base image already has browsers +# RUN npx playwright install # Allow to pass argument to codecept run via env variable ENV CODECEPT_ARGS="" diff --git a/README.md b/README.md index 992f36f4c..cbe95200b 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ You don't need to worry about asynchronous nature of NodeJS or about various API - Also plays nice with TypeScript. - Smart locators: use names, labels, matching text, CSS or XPath to locate elements. - 🌐 Interactive debugging shell: pause test at any point and try different commands in a browser. +- ⚡ **Parallel testing** with dynamic test pooling for optimal load balancing and performance. +- 📊 **Built-in HTML Reporter** with interactive dashboard, step-by-step execution details, and comprehensive test analytics. - Easily create tests, pageobjects, stepobjects with CLI generators. ## Installation @@ -233,6 +235,49 @@ Scenario('test title', () => { }) ``` +## HTML Reporter + +CodeceptJS includes a powerful built-in HTML Reporter that generates comprehensive, interactive test reports with detailed information about your test runs. The HTML reporter is **enabled by default** for all new projects and provides: + +### Features + +- **Interactive Dashboard**: Visual statistics, pie charts, and expandable test details +- **Step-by-Step Execution**: Shows individual test steps with timing and status indicators +- **BDD/Gherkin Support**: Full support for feature files with proper scenario formatting +- **System Information**: Comprehensive environment details including browser versions +- **Advanced Filtering**: Real-time filtering by status, tags, features, and test types +- **History Tracking**: Multi-run history with trend visualization +- **Error Details**: Clean formatting of error messages and stack traces +- **Artifacts Support**: Display screenshots and other test artifacts + +### Visual Examples + +#### Interactive Test Dashboard + +The main dashboard provides a complete overview with interactive statistics and pie charts: + +![HTML Reporter Dashboard](docs/shared/html-reporter-main-dashboard.png) + +#### Detailed Test Results + +Each test shows comprehensive execution details with expandable step information: + +![HTML Reporter Test Details](docs/shared/html-reporter-test-details.png) + +#### Advanced Filtering Capabilities + +Real-time filtering allows quick navigation through test results: + +![HTML Reporter Filtering](docs/shared/html-reporter-filtering.png) + +#### BDD/Gherkin Support + +Full support for Gherkin scenarios with proper feature formatting: + +![HTML Reporter BDD Details](docs/shared/html-reporter-bdd-details.png) + +The HTML reporter generates self-contained reports that can be easily shared with your team. Learn more about configuration and features in the [HTML Reporter documentation](https://codecept.io/plugins/#htmlreporter). + ## PageObjects CodeceptJS provides the most simple way to create and use page objects in your test. diff --git a/bin/codecept.js b/bin/codecept.js index 8a5d65b20..212f21639 100755 --- a/bin/codecept.js +++ b/bin/codecept.js @@ -165,6 +165,7 @@ program .option('--no-timeouts', 'disable all timeouts') .option('-p, --plugins ', 'enable plugins, comma-separated') .option('--shuffle', 'Shuffle the order in which test files run') + .option('--shard ', 'run only a fraction of tests (e.g., --shard 1/4)') // mocha options .option('--colors', 'force enabling of colors') @@ -196,6 +197,7 @@ program .option('-i, --invert', 'inverts --grep matches') .option('-o, --override [value]', 'override current config options') .option('--suites', 'parallel execution of suites not single tests') + .option('--by ', 'test distribution strategy: "test" (pre-assign individual tests), "suite" (pre-assign test suites), or "pool" (dynamic distribution for optimal load balancing, recommended)') .option(commandFlags.debug.flag, commandFlags.debug.description) .option(commandFlags.verbose.flag, commandFlags.verbose.description) .option('--features', 'run only *.feature files and skip tests') diff --git a/bin/test-server.js b/bin/test-server.js new file mode 100755 index 000000000..f413e5ea2 --- /dev/null +++ b/bin/test-server.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +/** + * Standalone test server script to replace json-server + */ + +const path = require('path') +const TestServer = require('../lib/test-server') + +// Parse command line arguments +const args = process.argv.slice(2) +let dbFile = path.join(__dirname, '../test/data/rest/db.json') +let port = 8010 +let host = '0.0.0.0' + +// Simple argument parsing +for (let i = 0; i < args.length; i++) { + const arg = args[i] + + if (arg === '-p' || arg === '--port') { + port = parseInt(args[++i]) + } else if (arg === '--host') { + host = args[++i] + } else if (!arg.startsWith('-')) { + dbFile = path.resolve(arg) + } +} + +// Create and start server +const server = new TestServer({ port, host, dbFile }) + +console.log(`Starting test server with db file: ${dbFile}`) + +server + .start() + .then(() => { + console.log(`Test server is ready and listening on http://${host}:${port}`) + }) + .catch(err => { + console.error('Failed to start test server:', err) + process.exit(1) + }) + +// Graceful shutdown +process.on('SIGINT', () => { + console.log('\nShutting down test server...') + server.stop().then(() => process.exit(0)) +}) + +process.on('SIGTERM', () => { + console.log('\nShutting down test server...') + server.stop().then(() => process.exit(0)) +}) diff --git a/docs/commands.md b/docs/commands.md index c90595641..bc554864c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -102,12 +102,32 @@ DEBUG=codeceptjs:* npx codeceptjs run ## Run Workers -Run tests in parallel threads. +Run tests in parallel threads. CodeceptJS supports different distribution strategies for optimal performance. -``` +```bash +# Run with 3 workers using default strategy (pre-assign tests) npx codeceptjs run-workers 3 + +# Run with pool mode for dynamic test distribution (recommended) +npx codeceptjs run-workers 3 --by pool + +# Run with suite distribution +npx codeceptjs run-workers 3 --by suite + +# Pool mode with filtering +npx codeceptjs run-workers 4 --by pool --grep "@smoke" ``` +**Test Distribution Strategies:** + +- `--by test` (default): Pre-assigns individual tests to workers +- `--by suite`: Pre-assigns entire test suites to workers +- `--by pool`: Dynamic distribution for optimal load balancing (recommended for best performance) + +The pool mode provides the best load balancing by maintaining tests in a shared pool and distributing them dynamically as workers become available. This prevents workers from sitting idle and ensures optimal CPU utilization, especially when tests have varying execution times. + +See [Parallel Execution](/parallel) documentation for more details. + ## Run Rerun Run tests multiple times to detect and fix flaky tests. diff --git a/docs/custom-locators-playwright.md b/docs/custom-locators-playwright.md new file mode 100644 index 000000000..58a04a62d --- /dev/null +++ b/docs/custom-locators-playwright.md @@ -0,0 +1,292 @@ +# Custom Locator Strategies - Playwright Helper + +This document describes how to configure and use custom locator strategies in the CodeceptJS Playwright helper. + +## Configuration + +Custom locator strategies can be configured in your `codecept.conf.js` file: + +```js +exports.config = { + helpers: { + Playwright: { + url: 'http://localhost:3000', + browser: 'chromium', + customLocatorStrategies: { + byRole: (selector, root) => { + return root.querySelector(`[role="${selector}"]`) + }, + byTestId: (selector, root) => { + return root.querySelector(`[data-testid="${selector}"]`) + }, + byDataQa: (selector, root) => { + const elements = root.querySelectorAll(`[data-qa="${selector}"]`) + return Array.from(elements) // Return array for multiple elements + }, + byAriaLabel: (selector, root) => { + return root.querySelector(`[aria-label="${selector}"]`) + }, + byPlaceholder: (selector, root) => { + return root.querySelector(`[placeholder="${selector}"]`) + }, + }, + }, + }, +} +``` + +## Usage + +Once configured, custom locator strategies can be used with the same syntax as other locator types: + +### Basic Usage + +```js +// Find and interact with elements +I.click({ byRole: 'button' }) +I.fillField({ byTestId: 'username' }, 'john_doe') +I.see('Welcome', { byAriaLabel: 'greeting' }) +I.seeElement({ byDataQa: 'navigation' }) +``` + +### Advanced Usage + +```js +// Use with within() blocks +within({ byRole: 'form' }, () => { + I.fillField({ byTestId: 'email' }, 'test@example.com') + I.click({ byRole: 'button' }) +}) + +// Mix with standard locators +I.seeElement({ byRole: 'main' }) +I.seeElement('#sidebar') // Standard CSS selector +I.seeElement({ xpath: '//div[@class="content"]' }) // Standard XPath + +// Use with grabbing methods +const text = I.grabTextFrom({ byTestId: 'status' }) +const value = I.grabValueFrom({ byPlaceholder: 'Enter email' }) + +// Use with waiting methods +I.waitForElement({ byRole: 'alert' }, 5) +I.waitForVisible({ byDataQa: 'loading-spinner' }, 3) +``` + +## Locator Function Requirements + +Custom locator functions must follow these requirements: + +### Function Signature + +```js +(selector, root) => HTMLElement | HTMLElement[] | null +``` + +- **selector**: The selector value passed to the locator +- **root**: The DOM element to search within (usually `document` or a parent element) +- **Return**: Single element, array of elements, or null/undefined if not found + +### Example Functions + +```js +customLocatorStrategies: { + // Single element selector + byRole: (selector, root) => { + return root.querySelector(`[role="${selector}"]`); + }, + + // Multiple elements selector (returns first for interactions) + byDataQa: (selector, root) => { + const elements = root.querySelectorAll(`[data-qa="${selector}"]`); + return Array.from(elements); + }, + + // Complex selector with validation + byCustomAttribute: (selector, root) => { + if (!selector) return null; + try { + return root.querySelector(`[data-custom="${selector}"]`); + } catch (error) { + console.warn('Invalid selector:', selector); + return null; + } + }, + + // Case-insensitive text search + byTextIgnoreCase: (selector, root) => { + const elements = Array.from(root.querySelectorAll('*')); + return elements.find(el => + el.textContent && + el.textContent.toLowerCase().includes(selector.toLowerCase()) + ); + } +} +``` + +## Error Handling + +The framework provides graceful error handling: + +### Undefined Strategies + +```js +// This will throw an error +I.click({ undefinedStrategy: 'value' }) +// Error: Please define "customLocatorStrategies" as an Object and the Locator Strategy as a "function". +``` + +### Malformed Functions + +If a custom locator function throws an error, it will be caught and logged: + +```js +byBrokenLocator: (selector, root) => { + throw new Error('This locator is broken') +} + +// Usage will log warning but not crash the test: +I.seeElement({ byBrokenLocator: 'test' }) // Logs warning, returns null +``` + +## Best Practices + +### 1. Naming Conventions + +Use descriptive names that clearly indicate what the locator does: + +```js +// Good +byRole: (selector, root) => root.querySelector(`[role="${selector}"]`), +byTestId: (selector, root) => root.querySelector(`[data-testid="${selector}"]`), + +// Avoid +by1: (selector, root) => root.querySelector(`[role="${selector}"]`), +custom: (selector, root) => root.querySelector(`[data-testid="${selector}"]`), +``` + +### 2. Error Handling + +Always include error handling in your custom functions: + +```js +byRole: (selector, root) => { + if (!selector || !root) return null + try { + return root.querySelector(`[role="${selector}"]`) + } catch (error) { + console.warn(`Error in byRole locator:`, error) + return null + } +} +``` + +### 3. Multiple Elements + +For selectors that may return multiple elements, return an array: + +```js +byClass: (selector, root) => { + const elements = root.querySelectorAll(`.${selector}`) + return Array.from(elements) // Convert NodeList to Array +} +``` + +### 4. Performance + +Keep locator functions simple and fast: + +```js +// Good - simple querySelector +byTestId: (selector, root) => root.querySelector(`[data-testid="${selector}"]`), + +// Avoid - complex DOM traversal +byComplexSearch: (selector, root) => { + // Avoid complex searches that iterate through many elements + return Array.from(root.querySelectorAll('*')) + .find(el => /* complex condition */); +} +``` + +## Testing Custom Locators + +### Unit Testing + +Test your custom locator functions independently: + +```js +describe('Custom Locators', () => { + it('should find elements by role', () => { + const mockRoot = { + querySelector: sinon.stub().returns(mockElement), + } + + const result = customLocatorStrategies.byRole('button', mockRoot) + expect(mockRoot.querySelector).to.have.been.calledWith('[role="button"]') + expect(result).to.equal(mockElement) + }) +}) +``` + +### Integration Testing + +Create acceptance tests that verify the locators work with real DOM: + +```js +Scenario('should use custom locators', I => { + I.amOnPage('/test-page') + I.seeElement({ byRole: 'navigation' }) + I.click({ byTestId: 'submit-button' }) + I.see('Success', { byAriaLabel: 'status-message' }) +}) +``` + +## Migration from Other Helpers + +If you're migrating from WebDriver helper that already supports custom locators, the syntax is identical: + +```js +// WebDriver and Playwright both support this syntax: +I.click({ byTestId: 'submit' }) +I.fillField({ byRole: 'textbox' }, 'value') +``` + +## Troubleshooting + +### Common Issues + +1. **Locator not recognized**: Ensure the strategy is defined in `customLocatorStrategies` and is a function. + +2. **Elements not found**: Check that your locator function returns the correct element or null. + +3. **Multiple elements**: If your function returns an array, interactions will use the first element. + +4. **Timing issues**: Custom locators work with all waiting methods (`waitForElement`, etc.). + +### Debug Mode + +Enable debug mode to see locator resolution: + +```js +// In codecept.conf.js +exports.config = { + helpers: { + Playwright: { + // ... other config + }, + }, + plugins: { + stepByStepReport: { + enabled: true, + }, + }, +} +``` + +### Verbose Logging + +Custom locator registration is logged when the helper starts: + +``` +Playwright: registering custom locator strategy: byRole +Playwright: registering custom locator strategy: byTestId +``` diff --git a/docs/helpers/Playwright.md b/docs/helpers/Playwright.md index 396349107..d03602539 100644 --- a/docs/helpers/Playwright.md +++ b/docs/helpers/Playwright.md @@ -81,6 +81,7 @@ Type: [object][6] * `highlightElement` **[boolean][26]?** highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose). * `recordHar` **[object][6]?** record HAR and will be saved to `output/har`. See more of [HAR options][3]. * `testIdAttribute` **[string][9]?** locate elements based on the testIdAttribute. See more of [locate by test id][49]. +* `customLocatorStrategies` **[object][6]?** custom locator strategies. An object with keys as strategy names and values as JavaScript functions. Example: `{ byRole: (selector, root) => { return root.querySelector(`[role="${selector}"]`) } }` diff --git a/docs/helpers/Puppeteer.md b/docs/helpers/Puppeteer.md index e236e04fc..98c658b11 100644 --- a/docs/helpers/Puppeteer.md +++ b/docs/helpers/Puppeteer.md @@ -60,6 +60,30 @@ Type: [object][4] * `chrome` **[object][4]?** pass additional [Puppeteer run options][28]. * `highlightElement` **[boolean][23]?** highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose). +## findElement + +Find a single element using Puppeteer's native element discovery methods +Note: Puppeteer Locator API doesn't have .first() method like Playwright + +### Parameters + +* `matcher` **[Object][4]** Puppeteer context to search within +* `locator` **([Object][4] | [string][6])** Locator specification + +Returns **[Promise][14]<[Object][4]>** Single ElementHandle object + +## findElements + +Find elements using Puppeteer's native element discovery methods +Note: Unlike Playwright, Puppeteer's Locator API doesn't have .all() method for multiple elements + +### Parameters + +* `matcher` **[Object][4]** Puppeteer context to search within +* `locator` **([Object][4] | [string][6])** Locator specification + +Returns **[Promise][14]<[Array][16]>** Array of ElementHandle objects + #### Trace Recording Customization @@ -231,6 +255,25 @@ Find a clickable element by providing human-readable text: this.helpers['Puppeteer']._locateClickable('Next page').then // ... ``` +#### Parameters + +* `locator` + +### _locateElement + +Get single element by different locator types, including strict locator +Should be used in custom helpers: + +```js +const element = await this.helpers['Puppeteer']._locateElement({name: 'password'}); +``` + + + + +This action supports [React locators](https://codecept.io/react#locators) + + #### Parameters * `locator` diff --git a/docs/internal-test-server.md b/docs/internal-test-server.md new file mode 100644 index 000000000..87488c42b --- /dev/null +++ b/docs/internal-test-server.md @@ -0,0 +1,89 @@ +# Internal API Test Server + +This directory contains the internal API test server implementation that replaces the third-party `json-server` dependency. + +## Files + +- `lib/test-server.js` - Main TestServer class implementation +- `bin/test-server.js` - CLI script to run the server standalone + +## Usage + +### As npm script: + +```bash +npm run test-server +``` + +### Directly: + +```bash +node bin/test-server.js [options] [db-file] +``` + +### Options: + +- `-p, --port ` - Port to listen on (default: 8010) +- `--host ` - Host to bind to (default: 0.0.0.0) +- `db-file` - Path to JSON database file (default: test/data/rest/db.json) + +## Features + +- **Full REST API compatibility** with json-server +- **Automatic file watching** - Reloads data when db.json file changes +- **CORS support** - Allows cross-origin requests for testing +- **Custom headers support** - Handles special headers like X-Test +- **File upload endpoints** - Basic file upload simulation +- **Express.js based** - Uses familiar Express.js framework + +## API Endpoints + +The server provides the same API endpoints as json-server: + +### Users + +- `GET /user` - Get user data +- `POST /user` - Create/update user +- `PATCH /user` - Partially update user +- `PUT /user` - Replace user + +### Posts + +- `GET /posts` - Get all posts +- `GET /posts/:id` - Get specific post +- `POST /posts` - Create new post +- `PUT /posts/:id` - Replace specific post +- `PATCH /posts/:id` - Partially update specific post +- `DELETE /posts/:id` - Delete specific post + +### Comments + +- `GET /comments` - Get all comments +- `POST /comments` - Create new comment +- `DELETE /comments/:id` - Delete specific comment + +### Utility + +- `GET /headers` - Return request headers (for testing) +- `POST /headers` - Return request headers (for testing) +- `POST /upload` - File upload simulation +- `POST /_reload` - Manually reload database file + +## Migration from json-server + +This server is designed as a drop-in replacement for json-server. The key differences: + +1. **No CLI options** - Configuration is done through constructor options or CLI args +2. **Automatic file watching** - No need for `--watch` flag +3. **Built-in middleware** - Headers and CORS are handled automatically +4. **Simpler file upload** - Basic implementation without full multipart support + +## Testing + +The server is used by the following test suites: + +- `test/rest/REST_test.js` - REST helper tests +- `test/rest/ApiDataFactory_test.js` - API data factory tests +- `test/helper/JSONResponse_test.js` - JSON response helper tests + +All tests pass with the internal server, proving full compatibility. diff --git a/docs/parallel.md b/docs/parallel.md index bea099046..9592c3f79 100644 --- a/docs/parallel.md +++ b/docs/parallel.md @@ -5,13 +5,71 @@ title: Parallel Execution # Parallel Execution -CodeceptJS has two engines for running tests in parallel: +CodeceptJS has multiple approaches for running tests in parallel: -* `run-workers` - which spawns [NodeJS Worker](https://nodejs.org/api/worker_threads.html) in a thread. Tests are split by scenarios, scenarios are mixed between groups, each worker runs tests from its own group. -* `run-multiple` - which spawns a subprocess with CodeceptJS. Tests are split by files and configured in `codecept.conf.js`. +- **Test Sharding** - distributes tests across multiple machines for CI matrix execution +- `run-workers` - which spawns [NodeJS Worker](https://nodejs.org/api/worker_threads.html) in a thread. Tests are split by scenarios, scenarios are mixed between groups, each worker runs tests from its own group. +- `run-multiple` - which spawns a subprocess with CodeceptJS. Tests are split by files and configured in `codecept.conf.js`. Workers are faster and simpler to start, while `run-multiple` requires additional configuration and can be used to run tests in different browsers at once. +## Test Sharding for CI Matrix + +Test sharding allows you to split your test suite across multiple machines or CI workers without manual configuration. This is particularly useful for CI/CD pipelines where you want to run tests in parallel across different machines. + +Use the `--shard` option with the `run` command to execute only a portion of your tests: + +```bash +# Run the first quarter of tests +npx codeceptjs run --shard 1/4 + +# Run the second quarter of tests +npx codeceptjs run --shard 2/4 + +# Run the third quarter of tests +npx codeceptjs run --shard 3/4 + +# Run the fourth quarter of tests +npx codeceptjs run --shard 4/4 +``` + +### CI Matrix Example + +Here's how you can use test sharding with GitHub Actions matrix strategy: + +```yaml +name: Tests +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + shard: [1/4, 2/4, 3/4, 4/4] + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + - run: npm install + - run: npx codeceptjs run --shard ${{ matrix.shard }} +``` + +This approach ensures: + +- Each CI job runs only its assigned portion of tests +- Tests are distributed evenly across shards +- No manual configuration or maintenance of test lists +- Automatic load balancing as you add or remove tests + +### Shard Distribution + +Tests are distributed evenly across shards using a round-robin approach: + +- If you have 100 tests and 4 shards, each shard runs approximately 25 tests +- The first shard gets tests 1-25, second gets 26-50, third gets 51-75, fourth gets 76-100 +- If tests don't divide evenly, earlier shards may get one extra test + ## Parallel Execution by Workers It is easy to run tests in parallel if you have a lots of tests and free CPU cores. Just execute your tests using `run-workers` command specifying the number of workers to spawn: @@ -32,6 +90,88 @@ By default, the tests are assigned one by one to the available workers this may npx codeceptjs run-workers --suites 2 ``` +### Test Distribution Strategies + +CodeceptJS supports three different strategies for distributing tests across workers: + +#### Default Strategy (`--by test`) +Tests are pre-assigned to workers at startup, distributing them evenly across all workers. Each worker gets a predetermined set of tests to run. + +```sh +npx codeceptjs run-workers 3 --by test +``` + +#### Suite Strategy (`--by suite`) +Test suites are pre-assigned to workers, with all tests in a suite running on the same worker. This ensures better test isolation but may lead to uneven load distribution. + +```sh +npx codeceptjs run-workers 3 --by suite +``` + +#### Pool Strategy (`--by pool`) - **Recommended for optimal performance** +Tests are maintained in a shared pool and distributed dynamically to workers as they become available. This provides the best load balancing and resource utilization. + +```sh +npx codeceptjs run-workers 3 --by pool +``` + +## Dynamic Test Pooling Mode + +The pool mode enables dynamic test distribution for improved worker load balancing. Instead of pre-assigning tests to workers at startup, tests are stored in a shared pool and distributed on-demand as workers become available. + +### Benefits of Pool Mode + +* **Better load balancing**: Workers never sit idle while others are still running long tests +* **Improved performance**: Especially beneficial when tests have varying execution times +* **Optimal resource utilization**: All CPU cores stay busy until the entire test suite is complete +* **Automatic scaling**: Workers continuously process tests until the pool is empty + +### When to Use Pool Mode + +Pool mode is particularly effective in these scenarios: + +* **Uneven test execution times**: When some tests take significantly longer than others +* **Large test suites**: With hundreds or thousands of tests where load balancing matters +* **Mixed test types**: When combining unit tests, integration tests, and end-to-end tests +* **CI/CD pipelines**: For consistent and predictable test execution times + +### Usage Examples + +```bash +# Basic pool mode with 4 workers +npx codeceptjs run-workers 4 --by pool + +# Pool mode with grep filtering +npx codeceptjs run-workers 3 --by pool --grep "@smoke" + +# Pool mode in debug mode +npx codeceptjs run-workers 2 --by pool --debug + +# Pool mode with specific configuration +npx codeceptjs run-workers 3 --by pool -c codecept.conf.js +``` + +### How Pool Mode Works + +1. **Pool Creation**: All tests are collected into a shared pool of test identifiers +2. **Worker Initialization**: The specified number of workers are spawned +3. **Dynamic Assignment**: Workers request tests from the pool when they're ready +4. **Continuous Processing**: Each worker runs one test, then immediately requests the next +5. **Automatic Completion**: Workers exit when the pool is empty and no more tests remain + +### Performance Comparison + +```bash +# Traditional mode - tests pre-assigned, some workers may finish early +npx codeceptjs run-workers 3 --by test # ✓ Good for uniform test times + +# Suite mode - entire suites assigned to workers +npx codeceptjs run-workers 3 --by suite # ✓ Good for test isolation + +# Pool mode - tests distributed dynamically +npx codeceptjs run-workers 3 --by pool # ✓ Best for mixed test execution times +``` + ## Test stats with Parallel Execution by Workers ```js @@ -128,27 +268,27 @@ FAIL | 7 passed, 1 failed, 1 skipped // 2s CodeceptJS also exposes the env var `process.env.RUNS_WITH_WORKERS` when running tests with `run-workers` command so that you could handle the events better in your plugins/helpers ```js -const { event } = require('codeceptjs'); +const { event } = require('codeceptjs') -module.exports = function() { - // this event would trigger the `_publishResultsToTestrail` when running `run-workers` command +module.exports = function () { + // this event would trigger the `_publishResultsToTestrail` when running `run-workers` command event.dispatcher.on(event.workers.result, async () => { - await _publishResultsToTestrail(); - }); - + await _publishResultsToTestrail() + }) + // this event would not trigger the `_publishResultsToTestrail` multiple times when running `run-workers` command event.dispatcher.on(event.all.result, async () => { - // when running `run` command, this env var is undefined - if (!process.env.RUNS_WITH_WORKERS) await _publishResultsToTestrail(); - }); + // when running `run` command, this env var is undefined + if (!process.env.RUNS_WITH_WORKERS) await _publishResultsToTestrail() + }) } ``` ## Parallel Execution by Workers on Multiple Browsers -To run tests in parallel across multiple browsers, modify your `codecept.conf.js` file to configure multiple browsers on which you want to run your tests and your tests will run across multiple browsers. +To run tests in parallel across multiple browsers, modify your `codecept.conf.js` file to configure multiple browsers on which you want to run your tests and your tests will run across multiple browsers. -Start with modifying the `codecept.conf.js` file. Add multiple key inside the config which will be used to configure multiple profiles. +Start with modifying the `codecept.conf.js` file. Add multiple key inside the config which will be used to configure multiple profiles. ``` exports.config = { @@ -174,7 +314,7 @@ exports.config = { } } ] - }, + }, profile2: { browsers: [ { @@ -188,16 +328,21 @@ exports.config = { } }; ``` -To trigger tests on all the profiles configured, you can use the following command: + +To trigger tests on all the profiles configured, you can use the following command: + ``` npx codeceptjs run-workers 3 all -c codecept.conf.js ``` + This will run your tests across all browsers configured from profile1 & profile2 on 3 workers. -To trigger tests on specific profile, you can use the following command: +To trigger tests on specific profile, you can use the following command: + ``` npx codeceptjs run-workers 2 profile1 -c codecept.conf.js ``` + This will run your tests across 2 browsers from profile1 on 2 workers. ## Custom Parallel Execution @@ -221,7 +366,7 @@ Create a placeholder in file: ```js #!/usr/bin/env node -const { Workers, event } = require('codeceptjs'); +const { Workers, event } = require('codeceptjs') // here will go magic ``` @@ -232,59 +377,59 @@ Now let's see how to update this file for different parallelization modes: ```js const workerConfig = { testConfig: './test/data/sandbox/codecept.customworker.js', -}; +} // don't initialize workers in constructor -const workers = new Workers(null, workerConfig); +const workers = new Workers(null, workerConfig) // split tests by suites in 2 groups -const testGroups = workers.createGroupsOfSuites(2); +const testGroups = workers.createGroupsOfSuites(2) -const browsers = ['firefox', 'chrome']; +const browsers = ['firefox', 'chrome'] const configs = browsers.map(browser => { return { helpers: { - WebDriver: { browser } - } - }; -}); + WebDriver: { browser }, + }, + } +}) for (const config of configs) { for (group of testGroups) { - const worker = workers.spawn(); - worker.addTests(group); - worker.addConfig(config); + const worker = workers.spawn() + worker.addTests(group) + worker.addConfig(config) } } // Listen events for failed test -workers.on(event.test.failed, (failedTest) => { - console.log('Failed : ', failedTest.title); -}); +workers.on(event.test.failed, failedTest => { + console.log('Failed : ', failedTest.title) +}) // Listen events for passed test -workers.on(event.test.passed, (successTest) => { - console.log('Passed : ', successTest.title); -}); +workers.on(event.test.passed, successTest => { + console.log('Passed : ', successTest.title) +}) // test run status will also be available in event workers.on(event.all.result, () => { // Use printResults() to display result with standard style - workers.printResults(); -}); + workers.printResults() +}) // run workers as async function -runWorkers(); +runWorkers() async function runWorkers() { try { // run bootstrapAll - await workers.bootstrapAll(); + await workers.bootstrapAll() // run tests - await workers.run(); + await workers.run() } finally { // run teardown All - await workers.teardownAll(); + await workers.teardownAll() } } ``` @@ -313,7 +458,6 @@ workers.on(event.all.result, (status, completedTests, workerStats) => { If you want your tests to split according to your need this method is suited for you. For example: If you have 4 long running test files and 4 normal test files there chance all 4 tests end up in same worker thread. For these cases custom function will be helpful. ```js - /* Define a function to split your tests. @@ -322,28 +466,25 @@ If you want your tests to split according to your need this method is suited for where file1 and file2 will run in a worker thread and file3 will run in a worker thread */ const splitTests = () => { - const files = [ - ['./test/data/sandbox/guthub_test.js', './test/data/sandbox/devto_test.js'], - ['./test/data/sandbox/longrunnig_test.js'] - ]; + const files = [['./test/data/sandbox/guthub_test.js', './test/data/sandbox/devto_test.js'], ['./test/data/sandbox/longrunnig_test.js']] - return files; + return files } const workerConfig = { testConfig: './test/data/sandbox/codecept.customworker.js', - by: splitTests -}; + by: splitTests, +} // don't initialize workers in constructor -const customWorkers = new Workers(null, workerConfig); +const customWorkers = new Workers(null, workerConfig) -customWorkers.run(); +customWorkers.run() // You can use event listeners similar to above example. customWorkers.on(event.all.result, () => { - workers.printResults(); -}); + workers.printResults() +}) ``` ### Emitting messages to the parent worker @@ -353,13 +494,13 @@ Child workers can send non-test events to the main process. This is useful if yo ```js // inside main process // listen for any non test related events -workers.on('message', (data) => { +workers.on('message', data => { console.log(data) -}); +}) workers.on(event.all.result, (status, completedTests, workerStats) => { // logic -}); +}) ``` ## Sharing Data Between Workers @@ -372,12 +513,12 @@ You can share data directly using the `share()` function and access it using `in ```js // In one test or worker -share({ userData: { name: 'user', password: '123456' } }); +share({ userData: { name: 'user', password: '123456' } }) // In another test or worker -const testData = inject(); -console.log(testData.userData.name); // 'user' -console.log(testData.userData.password); // '123456' +const testData = inject() +console.log(testData.userData.name) // 'user' +console.log(testData.userData.password) // '123456' ``` ### Initializing Data in Bootstrap @@ -389,20 +530,20 @@ For complex scenarios where you need to initialize shared data before tests run, exports.config = { bootstrap() { // Initialize shared data container - share({ userData: null, config: { retries: 3 } }); - } + share({ userData: null, config: { retries: 3 } }) + }, } ``` Then in your tests, you can check and update the shared data: ```js -const testData = inject(); +const testData = inject() if (!testData.userData) { // Update shared data - both approaches work: - share({ userData: { name: 'user', password: '123456' } }); + share({ userData: { name: 'user', password: '123456' } }) // or mutate the injected object: - testData.userData = { name: 'user', password: '123456' }; + testData.userData = { name: 'user', password: '123456' } } ``` @@ -412,24 +553,24 @@ Since CodeceptJS 3.7.0+, shared data uses Proxy objects for synchronization betw ```js // ✅ All of these work correctly: -const data = inject(); -console.log(data.userData.name); // Access nested properties -console.log(Object.keys(data)); // Enumerate shared keys -data.newProperty = 'value'; // Add new properties -Object.assign(data, { more: 'data' }); // Merge objects +const data = inject() +console.log(data.userData.name) // Access nested properties +console.log(Object.keys(data)) // Enumerate shared keys +data.newProperty = 'value' // Add new properties +Object.assign(data, { more: 'data' }) // Merge objects ``` **Important Note:** Avoid reassigning the entire injected object: ```js // ❌ AVOID: This breaks the proxy reference -let testData = inject(); -testData = someOtherObject; // This will NOT work as expected! +let testData = inject() +testData = someOtherObject // This will NOT work as expected! // ✅ PREFERRED: Use share() to replace data or mutate properties -share({ userData: someOtherObject }); // This works! +share({ userData: someOtherObject }) // This works! // or -Object.assign(inject(), someOtherObject); // This works! +Object.assign(inject(), someOtherObject) // This works! ``` ### Local Data (Worker-Specific) @@ -437,5 +578,5 @@ Object.assign(inject(), someOtherObject); // This works! If you want to share data only within the same worker (not across all workers), use the `local` option: ```js -share({ localData: 'worker-specific' }, { local: true }); +share({ localData: 'worker-specific' }, { local: true }) ``` diff --git a/docs/playwright.md b/docs/playwright.md index 9b12ed7ee..f43bbc0a9 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -130,6 +130,52 @@ I.fillField({name: 'user[email]'},'miles@davis.com'); I.seeElement({xpath: '//body/header'}); ``` +### Custom Locator Strategies + +CodeceptJS with Playwright supports custom locator strategies, allowing you to define your own element finding logic. Custom locator strategies are JavaScript functions that receive a selector value and return DOM elements. + +To use custom locator strategies, configure them in your `codecept.conf.js`: + +```js +exports.config = { + helpers: { + Playwright: { + url: 'http://localhost', + browser: 'chromium', + customLocatorStrategies: { + byRole: (selector, root) => { + return root.querySelector(`[role="${selector}"]`); + }, + byTestId: (selector, root) => { + return root.querySelector(`[data-testid="${selector}"]`); + }, + byDataQa: (selector, root) => { + const elements = root.querySelectorAll(`[data-qa="${selector}"]`); + return Array.from(elements); // Return array for multiple elements + } + } + } + } +} +``` + +Once configured, you can use these custom locator strategies in your tests: + +```js +I.click({byRole: 'button'}); // Find by role attribute +I.see('Welcome', {byTestId: 'title'}); // Find by data-testid +I.fillField({byDataQa: 'email'}, 'test@example.com'); +``` + +**Custom Locator Function Guidelines:** +- Functions receive `(selector, root)` parameters where `selector` is the value and `root` is the DOM context +- Return a single DOM element for finding the first match +- Return an array of DOM elements for finding all matches +- Return `null` or empty array if no elements found +- Functions execute in the browser context, so only browser APIs are available + +This feature provides the same functionality as WebDriver's custom locator strategies but leverages Playwright's native selector engine system. + ### Interactive Pause It's easy to start writing a test if you use [interactive pause](/basics#debug). Just open a web page and pause execution. diff --git a/docs/plugins.md b/docs/plugins.md index d726e636a..b3b20de8a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,6 +1,6 @@ --- permalink: plugins -sidebarDepth: +sidebarDepth: sidebar: auto title: Plugins --- @@ -24,42 +24,42 @@ exports.config = { enabled: true, clusterize: 5, analyze: 2, - vision: false, - }, - }, + vision: false + } + } } ``` #### Configuration -- `clusterize` (number) - minimum number of failures to trigger clustering analysis. Default: 5 -- `analyze` (number) - maximum number of individual test failures to analyze in detail. Default: 2 -- `vision` (boolean) - enables visual analysis of test screenshots. Default: false -- `categories` (array) - list of failure categories for classification. Defaults to: - - Browser connection error / browser crash - - Network errors (server error, timeout, etc) - - HTML / page elements (not found, not visible, etc) - - Navigation errors (404, etc) - - Code errors (syntax error, JS errors, etc) - - Library & framework errors - - Data errors (password incorrect, invalid format, etc) - - Assertion failures - - Other errors -- `prompts` (object) - customize AI prompts for analysis - - `clusterize` - prompt for clustering analysis - - `analyze` - prompt for individual test analysis +* `clusterize` (number) - minimum number of failures to trigger clustering analysis. Default: 5 +* `analyze` (number) - maximum number of individual test failures to analyze in detail. Default: 2 +* `vision` (boolean) - enables visual analysis of test screenshots. Default: false +* `categories` (array) - list of failure categories for classification. Defaults to: + * Browser connection error / browser crash + * Network errors (server error, timeout, etc) + * HTML / page elements (not found, not visible, etc) + * Navigation errors (404, etc) + * Code errors (syntax error, JS errors, etc) + * Library & framework errors + * Data errors (password incorrect, invalid format, etc) + * Assertion failures + * Other errors +* `prompts` (object) - customize AI prompts for analysis + * `clusterize` - prompt for clustering analysis + * `analyze` - prompt for individual test analysis #### Features -- Groups similar failures when number of failures >= clusterize value -- Provides detailed analysis of individual failures -- Analyzes screenshots if vision=true and screenshots are available -- Classifies failures into predefined categories -- Suggests possible causes and solutions +* Groups similar failures when number of failures >= clusterize value +* Provides detailed analysis of individual failures +* Analyzes screenshots if vision=true and screenshots are available +* Classifies failures into predefined categories +* Suggests possible causes and solutions ### Parameters -- `config` **[Object][1]** Plugin configuration (optional, default `{}`) +* `config` **[Object][1]** Plugin configuration (optional, default `{}`) Returns **void** @@ -81,28 +81,28 @@ If a session expires automatically logs in again. ```js // inside a test file // use login to inject auto-login function -Feature('Login') +Feature('Login'); Before(({ login }) => { - login('user') // login using user session -}) + login('user'); // login using user session +}); // Alternatively log in for one scenario. -Scenario('log me in', ({ I, login }) => { - login('admin') - I.see('I am logged in') -}) +Scenario('log me in', ( { I, login } ) => { + login('admin'); + I.see('I am logged in'); +}); ``` #### Configuration -- `saveToFile` (default: false) - save cookies to file. Allows to reuse session between execution. -- `inject` (default: `login`) - name of the login function to use -- `users` - an array containing different session names and functions to: - - `login` - sign in into the system - - `check` - check that user is logged in - - `fetch` - to get current cookies (by default `I.grabCookie()`) - - `restore` - to set cookies (by default `I.amOnPage('/'); I.setCookie(cookie)`) +* `saveToFile` (default: false) - save cookies to file. Allows to reuse session between execution. +* `inject` (default: `login`) - name of the login function to use +* `users` - an array containing different session names and functions to: + * `login` - sign in into the system + * `check` - check that user is logged in + * `fetch` - to get current cookies (by default `I.grabCookie()`) + * `restore` - to set cookies (by default `I.amOnPage('/'); I.setCookie(cookie)`) #### How It Works @@ -253,7 +253,7 @@ auth: { ``` ```js -Scenario('login', async ({ I, login }) => { +Scenario('login', async ( {I, login} ) => { await login('admin') // you should use `await` }) ``` @@ -287,14 +287,14 @@ auth: { ``` ```js -Scenario('login', async ({ I, login }) => { +Scenario('login', async ( {I, login} ) => { await login('admin') // you should use `await` }) ``` ### Parameters -- `config` +* `config` ## autoDelay @@ -309,32 +309,32 @@ It puts a tiny delay for before and after action commands. Commands affected (by default): -- `click` -- `fillField` -- `checkOption` -- `pressKey` -- `doubleClick` -- `rightClick` +* `click` +* `fillField` +* `checkOption` +* `pressKey` +* `doubleClick` +* `rightClick` #### Configuration ```js plugins: { - autoDelay: { - enabled: true - } + autoDelay: { + enabled: true + } } ``` Possible config options: -- `methods`: list of affected commands. Can be overridden -- `delayBefore`: put a delay before a command. 100ms by default -- `delayAfter`: put a delay after a command. 200ms by default +* `methods`: list of affected commands. Can be overridden +* `delayBefore`: put a delay before a command. 100ms by default +* `delayAfter`: put a delay after a command. 200ms by default ### Parameters -- `config` +* `config` ## commentStep @@ -343,16 +343,16 @@ This plugin is **deprecated**, use `Section` instead. Add descriptive nested steps for your tests: ```js -Scenario('project update test', async I => { - __`Given` - const projectId = await I.have('project') +Scenario('project update test', async (I) => { + __`Given`; + const projectId = await I.have('project'); - __`When` - projectPage.update(projectId, { title: 'new title' }) + __`When`; + projectPage.update(projectId, { title: 'new title' }); - __`Then` - projectPage.open(projectId) - I.see('new title', 'h1') + __`Then`; + projectPage.open(projectId); + I.see('new title', 'h1'); }) ``` @@ -372,8 +372,8 @@ This plugin can be used ### Config -- `enabled` - (default: false) enable a plugin -- `registerGlobal` - (default: false) register `__` template literal function globally. You can override function global name by providing a name as a value. +* `enabled` - (default: false) enable a plugin +* `registerGlobal` - (default: false) register `__` template literal function globally. You can override function global name by providing a name as a value. ### Examples @@ -414,11 +414,11 @@ For instance, you can prepare Given/When/Then functions to use them inside tests ```js // inside a test -const step = codeceptjs.container.plugins('commentStep') +const step = codeceptjs.container.plugins('commentStep'); -const Given = () => step`Given` -const When = () => step`When` -const Then = () => step`Then` +const Given = () => step`Given`; +const When = () => step`When`; +const Then = () => step`Then`; ``` Scenario('project update test', async (I) => { @@ -434,12 +434,11 @@ I.see('new title', 'h1'); }); ``` - ``` ### Parameters -- `config` +* `config` ## coverage @@ -460,15 +459,15 @@ plugins: { Possible config options, More could be found at [monocart-coverage-reports][2] -- `debug`: debug info. By default, false. -- `name`: coverage report name. -- `outputDir`: path to coverage report. -- `sourceFilter`: filter the source files. -- `sourcePath`: option to resolve a custom path. +* `debug`: debug info. By default, false. +* `name`: coverage report name. +* `outputDir`: path to coverage report. +* `sourceFilter`: filter the source files. +* `sourcePath`: option to resolve a custom path. ### Parameters -- `config` +* `config` ## customLocator @@ -488,11 +487,11 @@ This plugin will create a valid XPath locator for you. #### Configuration -- `enabled` (default: `false`) should a locator be enabled -- `prefix` (default: `$`) sets a prefix for a custom locator. -- `attribute` (default: `data-test-id`) to set an attribute to be matched. -- `strategy` (default: `xpath`) actual locator strategy to use in query (`css` or `xpath`). -- `showActual` (default: false) show in the output actually produced XPath or CSS locator. By default shows custom locator value. +* `enabled` (default: `false`) should a locator be enabled +* `prefix` (default: `$`) sets a prefix for a custom locator. +* `attribute` (default: `data-test-id`) to set an attribute to be matched. +* `strategy` (default: `xpath`) actual locator strategy to use in query (`css` or `xpath`). +* `showActual` (default: false) show in the output actually produced XPath or CSS locator. By default shows custom locator value. #### Examples: @@ -511,8 +510,8 @@ plugins: { In a test: ```js -I.seeElement('$user') // matches => [data-test=user] -I.click('$sign-up') // matches => [data-test=sign-up] +I.seeElement('$user'); // matches => [data-test=user] +I.click('$sign-up'); // matches => [data-test=sign-up] ``` Using `data-qa` attribute with `=` prefix: @@ -531,8 +530,8 @@ plugins: { In a test: ```js -I.seeElement('=user') // matches => [data-qa=user] -I.click('=sign-up') // matches => [data-qa=sign-up] +I.seeElement('=user'); // matches => [data-qa=user] +I.click('=sign-up'); // matches => [data-qa=sign-up] ``` Using `data-qa` OR `data-test` attribute with `=` prefix: @@ -552,8 +551,8 @@ plugins: { In a test: ```js -I.seeElement('=user') // matches => //*[@data-qa=user or @data-test=user] -I.click('=sign-up') // matches => //*[data-qa=sign-up or @data-test=sign-up] +I.seeElement('=user'); // matches => //*[@data-qa=user or @data-test=user] +I.click('=sign-up'); // matches => //*[data-qa=sign-up or @data-test=sign-up] ``` ```js @@ -571,13 +570,13 @@ plugins: { In a test: ```js -I.seeElement('=user') // matches => [data-qa=user],[data-test=user] -I.click('=sign-up') // matches => [data-qa=sign-up],[data-test=sign-up] +I.seeElement('=user'); // matches => [data-qa=user],[data-test=user] +I.click('=sign-up'); // matches => [data-qa=sign-up],[data-test=sign-up] ``` ### Parameters -- `config` +* `config` ## customReporter @@ -585,7 +584,7 @@ Sample custom reporter for CodeceptJS. ### Parameters -- `config` +* `config` ## eachElement @@ -593,17 +592,17 @@ Provides `eachElement` global function to iterate over found elements to perform `eachElement` takes following args: -- `purpose` - the goal of an action. A comment text that will be displayed in output. -- `locator` - a CSS/XPath locator to match elements -- `fn(element, index)` - **asynchronous** function which will be executed for each matched element. +* `purpose` - the goal of an action. A comment text that will be displayed in output. +* `locator` - a CSS/XPath locator to match elements +* `fn(element, index)` - **asynchronous** function which will be executed for each matched element. Example of usage: ```js // this example works with Playwright and Puppeteer helper -await eachElement('click all checkboxes', 'form input[type=checkbox]', async el => { - await el.click() -}) +await eachElement('click all checkboxes', 'form input[type=checkbox]', async (el) => { + await el.click(); +}); ``` Click odd elements: @@ -611,18 +610,18 @@ Click odd elements: ```js // this example works with Playwright and Puppeteer helper await eachElement('click odd buttons', '.button-select', async (el, index) => { - if (index % 2) await el.click() -}) + if (index % 2) await el.click(); +}); ``` Check all elements for visibility: ```js // this example works with Playwright and Puppeteer helper -const assert = require('assert') -await eachElement('check all items are visible', '.item', async el => { - assert(await el.isVisible()) -}) +const assert = require('assert'); +await eachElement('check all items are visible', '.item', async (el) => { + assert(await el.isVisible()); +}); ``` This method works with WebDriver, Playwright, Puppeteer, Appium helpers. @@ -630,25 +629,25 @@ This method works with WebDriver, Playwright, Puppeteer, Appium helpers. Function parameter `el` represents a matched element. Depending on a helper API of `el` can be different. Refer to API of corresponding browser testing engine for a complete API list: -- [Playwright ElementHandle][4] -- [Puppeteer][5] -- [webdriverio element][6] +* [Playwright ElementHandle][4] +* [Puppeteer][5] +* [webdriverio element][6] #### Configuration -- `registerGlobal` - to register `eachElement` function globally, true by default +* `registerGlobal` - to register `eachElement` function globally, true by default If `registerGlobal` is false you can use eachElement from the plugin: ```js -const eachElement = codeceptjs.container.plugins('eachElement') +const eachElement = codeceptjs.container.plugins('eachElement'); ``` ### Parameters -- `purpose` **[string][7]** -- `locator` **CodeceptJS.LocatorOrString** -- `fn` **[Function][8]** +* `purpose` **[string][7]** +* `locator` **CodeceptJS.LocatorOrString** +* `fn` **[Function][8]** Returns **([Promise][9]\ | [undefined][10])** @@ -670,9 +669,9 @@ Add this plugin to config file: ```js plugins: { - fakerTransform: { - enabled: true - } + fakerTransform: { + enabled: true + } } ``` @@ -690,7 +689,7 @@ Scenario Outline: ... ### Parameters -- `config` +* `config` ## heal @@ -708,11 +707,49 @@ plugins: { More config options are available: -- `healLimit` - how many steps can be healed in a single test (default: 2) +* `healLimit` - how many steps can be healed in a single test (default: 2) ### Parameters -- `config` (optional, default `{}`) +* `config` (optional, default `{}`) + +## htmlReporter + +HTML Reporter Plugin for CodeceptJS + +Generates comprehensive HTML reports showing: + +- Test statistics +- Feature/Scenario details +- Individual step results +- Test artifacts (screenshots, etc.) + +## Configuration + +```js +"plugins": { + "htmlReporter": { + "enabled": true, + "output": "./output", + "reportFileName": "report.html", + "includeArtifacts": true, + "showSteps": true, + "showSkipped": true, + "showMetadata": true, + "showTags": true, + "showRetries": true, + "exportStats": false, + "exportStatsPath": "./stats.json", + "keepHistory": false, + "historyPath": "./test-history.json", + "maxHistoryEntries": 50 + } +} +``` + +### Parameters + +- `config` ## pageInfo @@ -733,12 +770,12 @@ plugins: { Additional config options: -- `errorClasses` - list of classes to search for errors (default: `['error', 'warning', 'alert', 'danger']`) -- `browserLogs` - list of types of errors to search for in browser logs (default: `['error']`) +* `errorClasses` - list of classes to search for errors (default: `['error', 'warning', 'alert', 'danger']`) +* `browserLogs` - list of types of errors to search for in browser logs (default: `['error']`) ### Parameters -- `config` (optional, default `{}`) +* `config` (optional, default `{}`) ## pauseOnFail @@ -766,9 +803,9 @@ Add this plugin to config file: ```js plugins: { - retryFailedStep: { - enabled: true - } + retryFailedStep: { + enabled: true + } } ``` @@ -778,22 +815,22 @@ Run tests with plugin enabled: #### Configuration: -- `retries` - number of retries (by default 3), -- `when` - function, when to perform a retry (accepts error as parameter) -- `factor` - The exponential factor to use. Default is 1.5. -- `minTimeout` - The number of milliseconds before starting the first retry. Default is 1000. -- `maxTimeout` - The maximum number of milliseconds between two retries. Default is Infinity. -- `randomize` - Randomizes the timeouts by multiplying with a factor from 1 to 2. Default is false. -- `defaultIgnoredSteps` - an array of steps to be ignored for retry. Includes: - - `amOnPage` - - `wait*` - - `send*` - - `execute*` - - `run*` - - `have*` -- `ignoredSteps` - an array for custom steps to ignore on retry. Use it to append custom steps to ignored list. - You can use step names or step prefixes ending with `*`. As such, `wait*` will match all steps starting with `wait`. - To append your own steps to ignore list - copy and paste a default steps list. Regexp values are accepted as well. +* `retries` - number of retries (by default 3), +* `when` - function, when to perform a retry (accepts error as parameter) +* `factor` - The exponential factor to use. Default is 1.5. +* `minTimeout` - The number of milliseconds before starting the first retry. Default is 1000. +* `maxTimeout` - The maximum number of milliseconds between two retries. Default is Infinity. +* `randomize` - Randomizes the timeouts by multiplying with a factor from 1 to 2. Default is false. +* `defaultIgnoredSteps` - an array of steps to be ignored for retry. Includes: + * `amOnPage` + * `wait*` + * `send*` + * `execute*` + * `run*` + * `have*` +* `ignoredSteps` - an array for custom steps to ignore on retry. Use it to append custom steps to ignored list. + You can use step names or step prefixes ending with `*`. As such, `wait*` will match all steps starting with `wait`. + To append your own steps to ignore list - copy and paste a default steps list. Regexp values are accepted as well. #### Example @@ -817,13 +854,13 @@ Use scenario configuration to disable plugin for a test ```js Scenario('scenario tite', { disableRetryFailedStep: true }, () => { - // test goes here + // test goes here }) ``` ### Parameters -- `config` +* `config` ## screenshotOnFail @@ -839,20 +876,20 @@ Configuration can either be taken from a corresponding helper (deprecated) or a ```js plugins: { - screenshotOnFail: { - enabled: true - } + screenshotOnFail: { + enabled: true + } } ``` Possible config options: -- `uniqueScreenshotNames`: use unique names for screenshot. Default: false. -- `fullPageScreenshots`: make full page screenshots. Default: false. +* `uniqueScreenshotNames`: use unique names for screenshot. Default: false. +* `fullPageScreenshots`: make full page screenshots. Default: false. ### Parameters -- `config` +* `config` ## selenoid @@ -909,7 +946,7 @@ This is especially useful for Continous Integration server as you can configure 1. Create `browsers.json` file in the same directory `codecept.conf.js` is located [Refer to Selenoid documentation][16] to know more about browsers.json. -_Sample browsers.json_ +*Sample browsers.json* ```js { @@ -969,7 +1006,7 @@ When `allure` plugin is enabled a video is attached to report automatically. ### Parameters -- `config` +* `config` ## stepByStepReport @@ -995,17 +1032,17 @@ Run tests with plugin enabled: Possible config options: -- `deleteSuccessful`: do not save screenshots for successfully executed tests. Default: true. -- `animateSlides`: should animation for slides to be used. Default: true. -- `ignoreSteps`: steps to ignore in report. Array of RegExps is expected. Recommended to skip `grab*` and `wait*` steps. -- `fullPageScreenshots`: should full page screenshots be used. Default: false. -- `output`: a directory where reports should be stored. Default: `output`. -- `screenshotsForAllureReport`: If Allure plugin is enabled this plugin attaches each saved screenshot to allure report. Default: false. -- \`disableScreenshotOnFail : Disables the capturing of screeshots after the failed step. Default: true. +* `deleteSuccessful`: do not save screenshots for successfully executed tests. Default: true. +* `animateSlides`: should animation for slides to be used. Default: true. +* `ignoreSteps`: steps to ignore in report. Array of RegExps is expected. Recommended to skip `grab*` and `wait*` steps. +* `fullPageScreenshots`: should full page screenshots be used. Default: false. +* `output`: a directory where reports should be stored. Default: `output`. +* `screenshotsForAllureReport`: If Allure plugin is enabled this plugin attaches each saved screenshot to allure report. Default: false. +* \`disableScreenshotOnFail : Disables the capturing of screeshots after the failed step. Default: true. ### Parameters -- `config` **any** +* `config` **any** ## stepTimeout @@ -1015,9 +1052,9 @@ Add this plugin to config file: ```js plugins: { - stepTimeout: { - enabled: true - } + stepTimeout: { + enabled: true + } } ``` @@ -1027,18 +1064,19 @@ Run tests with plugin enabled: #### Configuration: -- `timeout` - global step timeout, default 150 seconds +* `timeout` - global step timeout, default 150 seconds -- `overrideStepLimits` - whether to use timeouts set in plugin config to override step timeouts set in code with I.limitTime(x).action(...), default false +* `overrideStepLimits` - whether to use timeouts set in plugin config to override step timeouts set in code with I.limitTime(x).action(...), default false -- `noTimeoutSteps` - an array of steps with no timeout. Default: - - `amOnPage` - - `wait*` +* `noTimeoutSteps` - an array of steps with no timeout. Default: - you could set your own noTimeoutSteps which would replace the default one. + * `amOnPage` + * `wait*` -- `customTimeoutSteps` - an array of step actions with custom timeout. Use it to override or extend noTimeoutSteps. - You can use step names or step prefixes ending with `*`. As such, `wait*` will match all steps starting with `wait`. + you could set your own noTimeoutSteps which would replace the default one. + +* `customTimeoutSteps` - an array of step actions with custom timeout. Use it to override or extend noTimeoutSteps. + You can use step names or step prefixes ending with `*`. As such, `wait*` will match all steps starting with `wait`. #### Example @@ -1061,7 +1099,7 @@ plugins: { ### Parameters -- `config` +* `config` ## subtitles @@ -1071,9 +1109,9 @@ Automatically captures steps as subtitle, and saves it as an artifact when a vid ```js plugins: { - subtitles: { - enabled: true - } + subtitles: { + enabled: true + } } ``` @@ -1083,11 +1121,11 @@ Webdriverio services runner. This plugin allows to run webdriverio services like: -- selenium-standalone -- sauce -- testingbot -- browserstack -- appium +* selenium-standalone +* sauce +* testingbot +* browserstack +* appium A complete list of all available services can be found on [webdriverio website][20]. @@ -1135,38 +1173,59 @@ plugins: { } ``` ---- +*** In the same manner additional services from webdriverio can be installed, enabled, and configured. #### Configuration -- `services` - list of enabled services -- ... - additional configuration passed into services. +* `services` - list of enabled services +* ... - additional configuration passed into services. ### Parameters -- `config` +* `config` [1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object + [2]: https://github.com/cenfun/monocart-coverage-reports?tab=readme-ov-file#default-options + [3]: https://codecept.io/locators#custom-locators + [4]: https://playwright.dev/docs/api/class-elementhandle + [5]: https://pptr.dev/#?product=Puppeteer&show=api-class-elementhandle + [6]: https://webdriver.io/docs/api + [7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String + [8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function + [9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise + [10]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined + [11]: https://codecept.io/heal/ + [12]: /basics/#pause + [13]: https://aerokube.com/selenoid/ + [14]: https://aerokube.com/cm/latest/ + [15]: https://hub.docker.com/u/selenoid + [16]: https://aerokube.com/selenoid/latest/#_prepare_configuration + [17]: https://aerokube.com/selenoid/latest/#_option_2_start_selenoid_container + [18]: https://docs.docker.com/engine/reference/commandline/create/ + [19]: https://codecept.io/img/codeceptjs-slideshow.gif + [20]: https://webdriver.io + [21]: https://webdriver.io/docs/selenium-standalone-service.html + [22]: https://webdriver.io/docs/sauce-service.html diff --git a/docs/reports.md b/docs/reports.md index bf444dfb3..07bf89bad 100644 --- a/docs/reports.md +++ b/docs/reports.md @@ -228,6 +228,66 @@ Result will be located at `output/result.xml` file. ## Html +### Built-in HTML Reporter + +CodeceptJS includes a built-in HTML reporter plugin that generates comprehensive HTML reports with detailed test information. + +#### Features + +- **Interactive Test Results**: Click on tests to expand and view detailed information +- **Step-by-Step Details**: Shows individual test steps with status indicators and timing +- **Test Statistics**: Visual cards showing totals, passed, failed, and pending test counts +- **Error Information**: Detailed error messages for failed tests with clean formatting +- **Artifacts Support**: Display screenshots and other test artifacts with modal viewing +- **Responsive Design**: Mobile-friendly layout that works on all screen sizes +- **Professional Styling**: Modern, clean interface with color-coded status indicators + +#### Configuration + +Add the `htmlReporter` plugin to your `codecept.conf.js`: + +```js +exports.config = { + // ... your other configuration + plugins: { + htmlReporter: { + enabled: true, + output: './output', // Directory for the report + reportFileName: 'report.html', // Name of the HTML file + includeArtifacts: true, // Include screenshots/artifacts + showSteps: true, // Show individual test steps + showSkipped: true // Show skipped tests + } + } +} +``` + +#### Configuration Options + +- `output` (optional, default: `./output`) - Directory where the HTML report will be saved +- `reportFileName` (optional, default: `'report.html'`) - Name of the generated HTML file +- `includeArtifacts` (optional, default: `true`) - Whether to include screenshots and other artifacts +- `showSteps` (optional, default: `true`) - Whether to display individual test steps +- `showSkipped` (optional, default: `true`) - Whether to include skipped tests in the report + +#### Usage + +Run your tests normally and the HTML report will be automatically generated: + +```sh +npx codeceptjs run +``` + +The report will be saved to `output/report.html` (or your configured location) and includes: + +- Overview statistics with visual cards +- Expandable test details showing steps and timing +- Error messages for failed tests +- Screenshots and artifacts (if available) +- Interactive failures section + +### Mochawesome + Best HTML reports could be produced with [mochawesome](https://www.npmjs.com/package/mochawesome) reporter. ![mochawesome](/img/mochawesome.png) diff --git a/docs/secrets.md b/docs/secrets.md index 4494575ba..95fc2fe9a 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -1,13 +1,15 @@ # Secrets -It is possible to **mask out sensitive data** when passing it to steps. This is important when filling password fields, or sending secure keys to API endpoint. +It is possible to **mask out sensitive data** when passing it to steps. This is important when filling password fields, or sending secure keys to API endpoint. CodeceptJS provides two approaches for masking sensitive data: + +## 1. Using the `secret()` Function Wrap data in `secret` function to mask sensitive values in output and logs. For basic string `secret` just wrap a value into a string: ```js -I.fillField('password', secret('123456')); +I.fillField('password', secret('123456')) ``` When executed it will be printed like this: @@ -15,22 +17,134 @@ When executed it will be printed like this: ``` I fill field "password" "*****" ``` + **Other Examples** + ```js -I.fillField('password', secret('123456')); -I.append('password', secret('123456')); -I.type('password', secret('123456')); +I.fillField('password', secret('123456')) +I.append('password', secret('123456')) +I.type('password', secret('123456')) ``` For an object, which can be a payload to POST request, specify which fields should be masked: ```js -I.sendPostRequest('/login', secret({ - name: 'davert', - password: '123456' -}, 'password')) +I.sendPostRequest( + '/login', + secret( + { + name: 'davert', + password: '123456', + }, + 'password', + ), +) +``` + +The object created from `secret` is as Proxy to the object passed in. When printed password will be replaced with \*\*\*\*. + +> ⚠️ Only direct properties of the object can be masked via `secret` + +## 2. Global Sensitive Data Masking + +CodeceptJS can automatically mask sensitive data in all output (logs, steps, debug messages, errors) using configurable patterns. This feature uses the `maskSensitiveData` configuration option. + +### Basic Usage (Boolean) + +Enable basic masking with predefined patterns: + +```js +// codecept.conf.js +exports.config = { + // ... other config + maskSensitiveData: true, +} +``` + +This will mask common sensitive data patterns like: + +- Authorization headers +- API keys +- Passwords +- Tokens +- Client secrets + +### Advanced Usage (Custom Patterns) + +Define your own masking patterns: + +```js +// codecept.conf.js +exports.config = { + // ... other config + maskSensitiveData: { + enabled: true, + patterns: [ + { + name: 'Email', + regex: /(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b)/gi, + mask: '[MASKED_EMAIL]', + }, + { + name: 'Credit Card', + regex: /\b(?:\d{4}[- ]?){3}\d{4}\b/g, + mask: '[MASKED_CARD]', + }, + { + name: 'Phone Number', + regex: /(\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})/g, + mask: '[MASKED_PHONE]', + }, + { + name: 'SSN', + regex: /\b\d{3}-\d{2}-\d{4}\b/g, + mask: '[MASKED_SSN]', + }, + ], + }, +} +``` + +### Pattern Configuration + +Each custom pattern object should have: + +- `name`: A descriptive name for the pattern +- `regex`: A JavaScript regular expression to match the sensitive data +- `mask`: The replacement string to show instead of the sensitive data + +### Examples + +With the above configuration: + +**Input:** + ``` +User email: john.doe@company.com +Credit card: 4111 1111 1111 1111 +Phone: +1-555-123-4567 +``` + +**Output:** + +``` +User email: [MASKED_EMAIL] +Credit card: [MASKED_CARD] +Phone: [MASKED_PHONE] +``` + +### Where Masking Applies + +Global sensitive data masking is applied to: + +- Step descriptions and output +- Debug messages (`--debug` mode) +- Log messages (`--verbose` mode) +- Error messages +- Success messages + +> ⚠️ Direct `console.log()` calls in helper functions are not masked. Use CodeceptJS output functions instead. -The object created from `secret` is as Proxy to the object passed in. When printed password will be replaced with ****. +### Combining Both Approaches -> ⚠️ Only direct properties of the object can be masked via `secret` \ No newline at end of file +You can use both `secret()` function and global masking together. The `secret()` function is applied first, then global patterns are applied to the remaining output. diff --git a/docs/shared/html-reporter-bdd-details.png b/docs/shared/html-reporter-bdd-details.png new file mode 100644 index 000000000..56db49b86 Binary files /dev/null and b/docs/shared/html-reporter-bdd-details.png differ diff --git a/docs/shared/html-reporter-filtering.png b/docs/shared/html-reporter-filtering.png new file mode 100644 index 000000000..519608324 Binary files /dev/null and b/docs/shared/html-reporter-filtering.png differ diff --git a/docs/shared/html-reporter-main-dashboard.png b/docs/shared/html-reporter-main-dashboard.png new file mode 100644 index 000000000..79bc68506 Binary files /dev/null and b/docs/shared/html-reporter-main-dashboard.png differ diff --git a/docs/shared/html-reporter-test-details.png b/docs/shared/html-reporter-test-details.png new file mode 100644 index 000000000..6227a158b Binary files /dev/null and b/docs/shared/html-reporter-test-details.png differ diff --git a/lib/codecept.js b/lib/codecept.js index 06752f593..59d77cd34 100644 --- a/lib/codecept.js +++ b/lib/codecept.js @@ -111,6 +111,7 @@ class Codecept { runHook(require('./listener/helpers')) runHook(require('./listener/globalTimeout')) runHook(require('./listener/globalRetry')) + runHook(require('./listener/retryEnhancer')) runHook(require('./listener/exit')) runHook(require('./listener/emptyRun')) @@ -185,6 +186,46 @@ class Codecept { if (this.opts.shuffle) { this.testFiles = shuffle(this.testFiles) } + + if (this.opts.shard) { + this.testFiles = this._applySharding(this.testFiles, this.opts.shard) + } + } + + /** + * Apply sharding to test files based on shard configuration + * + * @param {Array} testFiles - Array of test file paths + * @param {string} shardConfig - Shard configuration in format "index/total" (e.g., "1/4") + * @returns {Array} - Filtered array of test files for this shard + */ + _applySharding(testFiles, shardConfig) { + const shardMatch = shardConfig.match(/^(\d+)\/(\d+)$/) + if (!shardMatch) { + throw new Error('Invalid shard format. Expected format: "index/total" (e.g., "1/4")') + } + + const shardIndex = parseInt(shardMatch[1], 10) + const shardTotal = parseInt(shardMatch[2], 10) + + if (shardTotal < 1) { + throw new Error('Shard total must be at least 1') + } + + if (shardIndex < 1 || shardIndex > shardTotal) { + throw new Error(`Shard index ${shardIndex} must be between 1 and ${shardTotal}`) + } + + if (testFiles.length === 0) { + return testFiles + } + + // Calculate which tests belong to this shard + const shardSize = Math.ceil(testFiles.length / shardTotal) + const startIndex = (shardIndex - 1) * shardSize + const endIndex = Math.min(startIndex + shardSize, testFiles.length) + + return testFiles.slice(startIndex, endIndex) } /** diff --git a/lib/command/init.js b/lib/command/init.js index 735e16c23..ee5ba1294 100644 --- a/lib/command/init.js +++ b/lib/command/init.js @@ -18,6 +18,11 @@ const defaultConfig = { output: '', helpers: {}, include: {}, + plugins: { + htmlReporter: { + enabled: true, + }, + }, } const helpers = ['Playwright', 'WebDriver', 'Puppeteer', 'REST', 'GraphQL', 'Appium', 'TestCafe'] diff --git a/lib/command/run-workers.js b/lib/command/run-workers.js index 20a26e2c8..b5e3969fd 100644 --- a/lib/command/run-workers.js +++ b/lib/command/run-workers.js @@ -10,7 +10,22 @@ module.exports = async function (workerCount, selectedRuns, options) { const { config: testConfig, override = '' } = options const overrideConfigs = tryOrDefault(() => JSON.parse(override), {}) - const by = options.suites ? 'suite' : 'test' + + // Determine test split strategy + let by = 'test' // default + if (options.by) { + // Explicit --by option takes precedence + by = options.by + } else if (options.suites) { + // Legacy --suites option + by = 'suite' + } + + // Validate the by option + const validStrategies = ['test', 'suite', 'pool'] + if (!validStrategies.includes(by)) { + throw new Error(`Invalid --by strategy: ${by}. Valid options are: ${validStrategies.join(', ')}`) + } delete options.parent const config = { by, diff --git a/lib/command/workers/runTests.js b/lib/command/workers/runTests.js index d6222575a..f2f8cacd9 100644 --- a/lib/command/workers/runTests.js +++ b/lib/command/workers/runTests.js @@ -20,7 +20,7 @@ const stderr = '' // Requiring of Codecept need to be after tty.getWindowSize is available. const Codecept = require(process.env.CODECEPT_CLASS_PATH || '../../codecept') -const { options, tests, testRoot, workerIndex } = workerData +const { options, tests, testRoot, workerIndex, poolMode } = workerData // hide worker output if (!options.debug && !options.verbose) @@ -39,15 +39,26 @@ const codecept = new Codecept(config, options) codecept.init(testRoot) codecept.loadTests() const mocha = container.mocha() -filterTests() + +if (poolMode) { + // In pool mode, don't filter tests upfront - wait for assignments + // We'll reload test files fresh for each test request +} else { + // Legacy mode - filter tests upfront + filterTests() +} // run tests ;(async function () { - if (mocha.suite.total()) { + if (poolMode) { + await runPoolTests() + } else if (mocha.suite.total()) { await runTests() } })() +let globalStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 } + async function runTests() { try { await codecept.bootstrap() @@ -64,6 +75,192 @@ async function runTests() { } } +async function runPoolTests() { + try { + await codecept.bootstrap() + } catch (err) { + throw new Error(`Error while running bootstrap file :${err}`) + } + + initializeListeners() + disablePause() + + // Accumulate results across all tests in pool mode + let consolidatedStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 } + let allTests = [] + let allFailures = [] + let previousStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 } + + // Keep requesting tests until no more available + while (true) { + // Request a test assignment + sendToParentThread({ type: 'REQUEST_TEST', workerIndex }) + + const testResult = await new Promise((resolve, reject) => { + // Set up pool mode message handler + const messageHandler = async eventData => { + if (eventData.type === 'TEST_ASSIGNED') { + const testUid = eventData.test + + try { + // In pool mode, we need to create a fresh Mocha instance for each test + // because Mocha instances become disposed after running tests + container.createMocha() // Create fresh Mocha instance + filterTestById(testUid) + const mocha = container.mocha() + + if (mocha.suite.total() > 0) { + // Run the test and complete + await codecept.run() + + // Get the results from this specific test run + const result = container.result() + const currentStats = result.stats || {} + + // Calculate the difference from previous accumulated stats + const newPasses = Math.max(0, (currentStats.passes || 0) - previousStats.passes) + const newFailures = Math.max(0, (currentStats.failures || 0) - previousStats.failures) + const newTests = Math.max(0, (currentStats.tests || 0) - previousStats.tests) + const newPending = Math.max(0, (currentStats.pending || 0) - previousStats.pending) + const newFailedHooks = Math.max(0, (currentStats.failedHooks || 0) - previousStats.failedHooks) + + // Add only the new results + consolidatedStats.passes += newPasses + consolidatedStats.failures += newFailures + consolidatedStats.tests += newTests + consolidatedStats.pending += newPending + consolidatedStats.failedHooks += newFailedHooks + + // Update previous stats for next comparison + previousStats = { ...currentStats } + + // Add new failures to consolidated collections + if (result.failures && result.failures.length > allFailures.length) { + const newFailures = result.failures.slice(allFailures.length) + allFailures.push(...newFailures) + } + } + + // Signal test completed and request next + parentPort?.off('message', messageHandler) + resolve('TEST_COMPLETED') + } catch (err) { + parentPort?.off('message', messageHandler) + reject(err) + } + } else if (eventData.type === 'NO_MORE_TESTS') { + // No tests available, exit worker + parentPort?.off('message', messageHandler) + resolve('NO_MORE_TESTS') + } else { + // Handle other message types (support messages, etc.) + container.append({ support: eventData.data }) + } + } + + parentPort?.on('message', messageHandler) + }) + + // Exit if no more tests + if (testResult === 'NO_MORE_TESTS') { + break + } + } + + try { + await codecept.teardown() + } catch (err) { + // Log teardown errors but don't fail + console.error('Teardown error:', err) + } + + // Send final consolidated results for the entire worker + const finalResult = { + hasFailed: consolidatedStats.failures > 0, + stats: consolidatedStats, + duration: 0, // Pool mode doesn't track duration per worker + tests: [], // Keep tests empty to avoid serialization issues - stats are sufficient + failures: allFailures, // Include all failures for error reporting + } + + sendToParentThread({ event: event.all.after, workerIndex, data: finalResult }) + sendToParentThread({ event: event.all.result, workerIndex, data: finalResult }) + + // Add longer delay to ensure messages are delivered before closing + await new Promise(resolve => setTimeout(resolve, 100)) + + // Close worker thread when pool mode is complete + parentPort?.close() +} + +function filterTestById(testUid) { + // Reload test files fresh for each test in pool mode + const files = codecept.testFiles + + // Get the existing mocha instance + const mocha = container.mocha() + + // Clear suites and tests but preserve other mocha settings + mocha.suite.suites = [] + mocha.suite.tests = [] + + // Clear require cache for test files to ensure fresh loading + files.forEach(file => { + delete require.cache[require.resolve(file)] + }) + + // Set files and load them + mocha.files = files + mocha.loadFiles() + + // Now filter to only the target test - use a more robust approach + let foundTest = false + for (const suite of mocha.suite.suites) { + const originalTests = [...suite.tests] + suite.tests = [] + + for (const test of originalTests) { + if (test.uid === testUid) { + suite.tests.push(test) + foundTest = true + break // Only add one matching test + } + } + + // If no tests found in this suite, remove it + if (suite.tests.length === 0) { + suite.parent.suites = suite.parent.suites.filter(s => s !== suite) + } + } + + // Filter out empty suites from the root + mocha.suite.suites = mocha.suite.suites.filter(suite => suite.tests.length > 0) + + if (!foundTest) { + // If testUid doesn't match, maybe it's a simple test name - try fallback + mocha.suite.suites = [] + mocha.suite.tests = [] + mocha.loadFiles() + + // Try matching by title + for (const suite of mocha.suite.suites) { + const originalTests = [...suite.tests] + suite.tests = [] + + for (const test of originalTests) { + if (test.title === testUid || test.fullTitle() === testUid || test.uid === testUid) { + suite.tests.push(test) + foundTest = true + break + } + } + } + + // Clean up empty suites again + mocha.suite.suites = mocha.suite.suites.filter(suite => suite.tests.length > 0) + } +} + function filterTests() { const files = codecept.testFiles mocha.files = files @@ -102,14 +299,20 @@ function initializeListeners() { event.dispatcher.on(event.hook.passed, hook => sendToParentThread({ event: event.hook.passed, workerIndex, data: hook.simplify() })) event.dispatcher.on(event.hook.finished, hook => sendToParentThread({ event: event.hook.finished, workerIndex, data: hook.simplify() })) - event.dispatcher.once(event.all.after, () => { - sendToParentThread({ event: event.all.after, workerIndex, data: container.result().simplify() }) - }) - // all - event.dispatcher.once(event.all.result, () => { - sendToParentThread({ event: event.all.result, workerIndex, data: container.result().simplify() }) - parentPort?.close() - }) + if (!poolMode) { + // In regular mode, close worker after all tests are complete + event.dispatcher.once(event.all.after, () => { + sendToParentThread({ event: event.all.after, workerIndex, data: container.result().simplify() }) + }) + // all + event.dispatcher.once(event.all.result, () => { + sendToParentThread({ event: event.all.result, workerIndex, data: container.result().simplify() }) + parentPort?.close() + }) + } else { + // In pool mode, don't send result events for individual tests + // Results will be sent once when the worker completes all tests + } } function disablePause() { @@ -121,7 +324,10 @@ function sendToParentThread(data) { } function listenToParentThread() { - parentPort?.on('message', eventData => { - container.append({ support: eventData.data }) - }) + if (!poolMode) { + parentPort?.on('message', eventData => { + container.append({ support: eventData.data }) + }) + } + // In pool mode, message handling is done in runPoolTests() } diff --git a/lib/helper/JSONResponse.js b/lib/helper/JSONResponse.js index 908d4d0bf..bc02f934a 100644 --- a/lib/helper/JSONResponse.js +++ b/lib/helper/JSONResponse.js @@ -72,10 +72,11 @@ class JSONResponse extends Helper { if (!this.helpers[this.options.requestHelper]) { throw new Error(`Error setting JSONResponse, helper ${this.options.requestHelper} is not enabled in config, helpers: ${Object.keys(this.helpers)}`) } - // connect to REST helper + const origOnResponse = this.helpers[this.options.requestHelper].config.onResponse; this.helpers[this.options.requestHelper].config.onResponse = response => { - this.response = response - } + this.response = response; + if (typeof origOnResponse === 'function') origOnResponse(response); + }; } _before() { diff --git a/lib/helper/Mochawesome.js b/lib/helper/Mochawesome.js index 0f45ff723..181ba414e 100644 --- a/lib/helper/Mochawesome.js +++ b/lib/helper/Mochawesome.js @@ -37,7 +37,20 @@ class Mochawesome extends Helper { } _test(test) { - currentTest = { test } + // If this is a retried test, we want to add context to the retried test + // but also potentially preserve context from the original test + const originalTest = test.retriedTest && test.retriedTest() + if (originalTest) { + // This is a retried test - use the retried test for context + currentTest = { test } + + // Optionally copy context from original test if it exists + // Note: mochawesome context is stored in test.ctx, but we need to be careful + // not to break the mocha context structure + } else { + // Normal test (not a retry) + currentTest = { test } + } } _failed(test) { @@ -64,7 +77,16 @@ class Mochawesome extends Helper { addMochawesomeContext(context) { if (currentTest === '') currentTest = { test: currentSuite.ctx.test } - return this._addContext(currentTest, context) + + // For retried tests, make sure we're adding context to the current (retried) test + // not the original test + let targetTest = currentTest + if (currentTest.test && currentTest.test.retriedTest && currentTest.test.retriedTest()) { + // This test has been retried, make sure we're using the current test for context + targetTest = { test: currentTest.test } + } + + return this._addContext(targetTest, context) } } diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 04e9693ba..70b4f6c14 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -38,6 +38,8 @@ const WebElement = require('../element/WebElement') let playwright let perfTiming let defaultSelectorEnginesInitialized = false +let registeredCustomLocatorStrategies = new Set() +let globalCustomLocatorStrategies = new Map() const popupStore = new Popup() const consoleLogStore = new Console() @@ -96,6 +98,7 @@ const pathSeparator = path.sep * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose). * @prop {object} [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har). * @prop {string} [testIdAttribute=data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id). + * @prop {object} [customLocatorStrategies] - custom locator strategies. An object with keys as strategy names and values as JavaScript functions. Example: `{ byRole: (selector, root) => { return root.querySelector(\`[role="\${selector}\"]\`) } }` */ const config = {} @@ -344,9 +347,23 @@ class Playwright extends Helper { this.recordingWebSocketMessages = false this.recordedWebSocketMessagesAtLeastOnce = false this.cdpSession = null + this.customLocatorStrategies = typeof config.customLocatorStrategies === 'object' && config.customLocatorStrategies !== null ? config.customLocatorStrategies : null + this._customLocatorsRegistered = false + + // Add custom locator strategies to global registry for early registration + if (this.customLocatorStrategies) { + for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) { + globalCustomLocatorStrategies.set(strategyName, strategyFunction) + } + } // override defaults with config this._setConfig(config) + + // Call _init() to register selector engines - use setTimeout to avoid blocking constructor + setTimeout(() => { + this._init().catch(console.error) + }, 0) } _validateConfig(config) { @@ -463,12 +480,61 @@ class Playwright extends Helper { async _init() { // register an internal selector engine for reading value property of elements in a selector - if (defaultSelectorEnginesInitialized) return - defaultSelectorEnginesInitialized = true try { - await playwright.selectors.register('__value', createValueEngine) - await playwright.selectors.register('__disabled', createDisabledEngine) - if (process.env.testIdAttribute) await playwright.selectors.setTestIdAttribute(process.env.testIdAttribute) + if (!defaultSelectorEnginesInitialized) { + await playwright.selectors.register('__value', createValueEngine) + await playwright.selectors.register('__disabled', createDisabledEngine) + if (process.env.testIdAttribute) await playwright.selectors.setTestIdAttribute(process.env.testIdAttribute) + defaultSelectorEnginesInitialized = true + } + + // Register all custom locator strategies from the global registry + for (const [strategyName, strategyFunction] of globalCustomLocatorStrategies.entries()) { + if (!registeredCustomLocatorStrategies.has(strategyName)) { + try { + // Create a selector engine factory function exactly like createValueEngine pattern + // Capture variables in closure to avoid reference issues + const createCustomEngine = ((name, func) => { + return () => { + return { + create() { + return null + }, + query(root, selector) { + try { + if (!root) return null + const result = func(selector, root) + return Array.isArray(result) ? result[0] : result + } catch (error) { + console.warn(`Error in custom locator "${name}":`, error) + return null + } + }, + queryAll(root, selector) { + try { + if (!root) return [] + const result = func(selector, root) + return Array.isArray(result) ? result : result ? [result] : [] + } catch (error) { + console.warn(`Error in custom locator "${name}":`, error) + return [] + } + }, + } + } + })(strategyName, strategyFunction) + + await playwright.selectors.register(strategyName, createCustomEngine) + registeredCustomLocatorStrategies.add(strategyName) + } catch (error) { + if (!error.message.includes('already registered')) { + console.warn(`Failed to register custom locator strategy '${strategyName}':`, error) + } else { + console.log(`Custom locator strategy '${strategyName}' already registered`) + } + } + } + } } catch (e) { console.warn(e) } @@ -827,6 +893,9 @@ class Playwright extends Helper { } async _startBrowser() { + // Ensure custom locator strategies are registered before browser launch + await this._init() + if (this.isElectron) { this.browser = await playwright._electron.launch(this.playwrightOptions) } else if (this.isRemoteBrowser && this.isCDPConnection) { @@ -862,6 +931,30 @@ class Playwright extends Helper { return this.browser } + _lookupCustomLocator(customStrategy) { + if (typeof this.customLocatorStrategies !== 'object' || this.customLocatorStrategies === null) { + return null + } + const strategy = this.customLocatorStrategies[customStrategy] + return typeof strategy === 'function' ? strategy : null + } + + _isCustomLocator(locator) { + const locatorObj = new Locator(locator) + if (locatorObj.isCustom()) { + const customLocator = this._lookupCustomLocator(locatorObj.type) + if (customLocator) { + return true + } + throw new Error('Please define "customLocatorStrategies" as an Object and the Locator Strategy as a "function".') + } + return false + } + + _isCustomLocatorStrategyDefined() { + return !!(this.customLocatorStrategies && Object.keys(this.customLocatorStrategies).length > 0) + } + /** * Create a new browser context with a page. \ * Usually it should be run from a custom helper after call of `_startBrowser()` @@ -869,11 +962,64 @@ class Playwright extends Helper { */ async _createContextPage(contextOptions) { this.browserContext = await this.browser.newContext(contextOptions) + + // Register custom locator strategies for this context + await this._registerCustomLocatorStrategies() + const page = await this.browserContext.newPage() targetCreatedHandler.call(this, page) await this._setPage(page) } + async _registerCustomLocatorStrategies() { + if (!this.customLocatorStrategies) return + + for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) { + if (!registeredCustomLocatorStrategies.has(strategyName)) { + try { + const createCustomEngine = ((name, func) => { + return () => { + return { + create(root, target) { + return null + }, + query(root, selector) { + try { + if (!root) return null + const result = func(selector, root) + return Array.isArray(result) ? result[0] : result + } catch (error) { + console.warn(`Error in custom locator "${name}":`, error) + return null + } + }, + queryAll(root, selector) { + try { + if (!root) return [] + const result = func(selector, root) + return Array.isArray(result) ? result : result ? [result] : [] + } catch (error) { + console.warn(`Error in custom locator "${name}":`, error) + return [] + } + }, + } + } + })(strategyName, strategyFunction) + + await playwright.selectors.register(strategyName, createCustomEngine) + registeredCustomLocatorStrategies.add(strategyName) + } catch (error) { + if (!error.message.includes('already registered')) { + console.warn(`Failed to register custom locator strategy '${strategyName}':`, error) + } else { + console.log(`Custom locator strategy '${strategyName}' already registered`) + } + } + } + } + } + _getType() { return this.browser._type } @@ -885,7 +1031,10 @@ class Playwright extends Helper { this.frame = null popupStore.clear() if (this.options.recordHar) await this.browserContext.close() + this.browserContext = null await this.browser.close() + this.browser = null + this.isRunning = false } async _evaluateHandeInContext(...args) { @@ -1266,9 +1415,9 @@ class Playwright extends Helper { async _locate(locator) { const context = await this._getContext() - if (this.frame) return findElements(this.frame, locator) + if (this.frame) return findElements.call(this, this.frame, locator) - const els = await findElements(context, locator) + const els = await findElements.call(this, context, locator) if (store.debugMode) { const previewElements = els.slice(0, 3) @@ -2063,11 +2212,25 @@ class Playwright extends Helper { * @param {*} locator */ _contextLocator(locator) { - locator = buildLocatorString(new Locator(locator, 'css')) + const locatorObj = new Locator(locator, 'css') + + // Handle custom locators differently + if (locatorObj.isCustom()) { + return buildCustomLocatorString(locatorObj) + } + + locator = buildLocatorString(locatorObj) if (this.contextLocator) { - const contextLocator = buildLocatorString(new Locator(this.contextLocator, 'css')) - locator = `${contextLocator} >> ${locator}` + const contextLocatorObj = new Locator(this.contextLocator, 'css') + if (contextLocatorObj.isCustom()) { + // For custom context locators, we can't use the >> syntax + // Instead, we'll need to handle this differently in the calling methods + return locator + } else { + const contextLocator = buildLocatorString(contextLocatorObj) + locator = `${contextLocator} >> ${locator}` + } } return locator @@ -2078,11 +2241,25 @@ class Playwright extends Helper { * */ async grabTextFrom(locator) { - locator = this._contextLocator(locator) - const text = await this.page.textContent(locator) - assertElementExists(text, locator) - this.debugSection('Text', text) - return text + const locatorObj = new Locator(locator, 'css') + + if (locatorObj.isCustom()) { + // For custom locators, find the element first + const elements = await findCustomElements.call(this, this.page, locatorObj) + if (elements.length === 0) { + throw new Error(`Element not found: ${locatorObj.toString()}`) + } + const text = await elements[0].textContent() + assertElementExists(text, locatorObj.toString()) + this.debugSection('Text', text) + return text + } else { + locator = this._contextLocator(locator) + const text = await this.page.textContent(locator) + assertElementExists(text, locator) + this.debugSection('Text', text) + return text + } } /** @@ -2095,7 +2272,6 @@ class Playwright extends Helper { for (const el of els) { texts.push(await el.innerText()) } - this.debug(`Matched ${els.length} elements`) return texts } @@ -2114,7 +2290,6 @@ class Playwright extends Helper { */ async grabValueFromAll(locator) { const els = await findFields.call(this, locator) - this.debug(`Matched ${els.length} elements`) return Promise.all(els.map(el => el.inputValue())) } @@ -2133,7 +2308,6 @@ class Playwright extends Helper { */ async grabHTMLFromAll(locator) { const els = await this._locate(locator) - this.debug(`Matched ${els.length} elements`) return Promise.all(els.map(el => el.innerHTML())) } @@ -2154,7 +2328,6 @@ class Playwright extends Helper { */ async grabCssPropertyFromAll(locator, cssProperty) { const els = await this._locate(locator) - this.debug(`Matched ${els.length} elements`) const cssValues = await Promise.all(els.map(el => el.evaluate((el, cssProperty) => getComputedStyle(el).getPropertyValue(cssProperty), cssProperty))) return cssValues @@ -2265,7 +2438,6 @@ class Playwright extends Helper { */ async grabAttributeFromAll(locator, attr) { const els = await this._locate(locator) - this.debug(`Matched ${els.length} elements`) const array = [] for (let index = 0; index < els.length; index++) { @@ -2285,7 +2457,6 @@ class Playwright extends Helper { const res = await this._locateElement(locator) assertElementExists(res, locator) const elem = res - this.debug(`Screenshot of ${new Locator(locator)} element has been saved to ${outputFile}`) return elem.screenshot({ path: outputFile, type: 'png' }) } @@ -2580,7 +2751,16 @@ class Playwright extends Helper { const context = await this._getContext() try { - await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' }) + if (locator.isCustom()) { + // For custom locators, we need to use our custom element finding logic + const elements = await findCustomElements.call(this, context, locator) + if (elements.length === 0) { + throw new Error(`Custom locator ${locator.type}=${locator.value} not found`) + } + await elements[0].waitFor({ timeout: waitTimeout, state: 'attached' }) + } else { + await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' }) + } } catch (e) { throw new Error(`element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${e.message}`) } @@ -2594,9 +2774,30 @@ class Playwright extends Helper { async waitForVisible(locator, sec) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout locator = new Locator(locator, 'css') + const context = await this._getContext() let count = 0 + // Handle custom locators + if (locator.isCustom()) { + let waiter + do { + const elements = await findCustomElements.call(this, context, locator) + if (elements.length > 0) { + waiter = await elements[0].isVisible() + } else { + waiter = false + } + if (!waiter) { + await this.wait(1) + count += 1000 + } + } while (!waiter && count <= waitTimeout) + + if (!waiter) throw new Error(`element (${locator.toString()}) still not visible after ${waitTimeout / 1000} sec.`) + return + } + // we have this as https://github.com/microsoft/playwright/issues/26829 is not yet implemented let waiter if (this.frame) { @@ -2623,6 +2824,7 @@ class Playwright extends Helper { async waitForInvisible(locator, sec) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout locator = new Locator(locator, 'css') + const context = await this._getContext() let waiter let count = 0 @@ -2653,6 +2855,7 @@ class Playwright extends Helper { async waitToHide(locator, sec) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout locator = new Locator(locator, 'css') + const context = await this._getContext() let waiter let count = 0 @@ -2774,9 +2977,18 @@ class Playwright extends Helper { if (context) { const locator = new Locator(context, 'css') try { + if (locator.isCustom()) { + // For custom locators, find the elements first then check for text within them + const elements = await findCustomElements.call(this, contextObject, locator) + if (elements.length === 0) { + throw new Error(`Context element not found: ${locator.toString()}`) + } + return elements[0].locator(`text=${text}`).first().waitFor({ timeout: waitTimeout, state: 'visible' }) + } + if (!locator.isXPath()) { return contextObject - .locator(`${locator.isCustom() ? `${locator.type}=${locator.value}` : locator.simplify()} >> text=${text}`) + .locator(`${locator.simplify()} >> text=${text}`) .first() .waitFor({ timeout: waitTimeout, state: 'visible' }) .catch(e => { @@ -3421,9 +3633,15 @@ class Playwright extends Helper { module.exports = Playwright +function buildCustomLocatorString(locator) { + // Note: this.debug not available in standalone function, using console.log + console.log(`Building custom locator string: ${locator.type}=${locator.value}`) + return `${locator.type}=${locator.value}` +} + function buildLocatorString(locator) { if (locator.isCustom()) { - return `${locator.type}=${locator.value}` + return buildCustomLocatorString(locator) } if (locator.isXPath()) { return `xpath=${locator.value}` @@ -3435,15 +3653,119 @@ async function findElements(matcher, locator) { if (locator.react) return findReact(matcher, locator) if (locator.vue) return findVue(matcher, locator) if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) + locator = new Locator(locator, 'css') - return matcher.locator(buildLocatorString(locator)).all() + // Handle custom locators directly instead of relying on Playwright selector engines + if (locator.isCustom()) { + return findCustomElements.call(this, matcher, locator) + } + + // Check if we have a custom context locator and need to search within it + if (this.contextLocator) { + const contextLocatorObj = new Locator(this.contextLocator, 'css') + if (contextLocatorObj.isCustom()) { + // Find the context elements first + const contextElements = await findCustomElements.call(this, matcher, contextLocatorObj) + if (contextElements.length === 0) { + return [] + } + + // Search within the first context element + const locatorString = buildLocatorString(locator) + return contextElements[0].locator(locatorString).all() + } + } + + const locatorString = buildLocatorString(locator) + + return matcher.locator(locatorString).all() +} + +async function findCustomElements(matcher, locator) { + const customLocatorStrategies = this.customLocatorStrategies || globalCustomLocatorStrategies + const strategyFunction = customLocatorStrategies.get ? customLocatorStrategies.get(locator.type) : customLocatorStrategies[locator.type] + + if (!strategyFunction) { + throw new Error(`Custom locator strategy "${locator.type}" is not defined. Please define "customLocatorStrategies" in your configuration.`) + } + + // Execute the custom locator function in the browser context using page.evaluate + const page = matcher.constructor.name === 'Page' ? matcher : await matcher.page() + + const elements = await page.evaluate( + ({ strategyCode, selector }) => { + const strategy = new Function('return ' + strategyCode)() + const result = strategy(selector, document) + + // Convert NodeList or single element to array + if (result && result.nodeType) { + return [result] + } else if (result && result.length !== undefined) { + return Array.from(result) + } else if (Array.isArray(result)) { + return result + } + + return [] + }, + { + strategyCode: strategyFunction.toString(), + selector: locator.value, + }, + ) + + // Convert the found elements back to Playwright locators + if (elements.length === 0) { + return [] + } + + // Create CSS selectors for the found elements and return as locators + const locators = [] + const timestamp = Date.now() + + for (let i = 0; i < elements.length; i++) { + // Use a unique attribute approach to target specific elements + const uniqueAttr = `data-codecept-custom-${timestamp}-${i}` + + await page.evaluate( + ({ index, uniqueAttr, strategyCode, selector }) => { + // Re-execute the strategy to find elements and mark the specific one + const strategy = new Function('return ' + strategyCode)() + const result = strategy(selector, document) + + let elementsArray = [] + if (result && result.nodeType) { + elementsArray = [result] + } else if (result && result.length !== undefined) { + elementsArray = Array.from(result) + } else if (Array.isArray(result)) { + elementsArray = result + } + + if (elementsArray[index]) { + elementsArray[index].setAttribute(uniqueAttr, 'true') + } + }, + { + index: i, + uniqueAttr, + strategyCode: strategyFunction.toString(), + selector: locator.value, + }, + ) + + locators.push(page.locator(`[${uniqueAttr}="true"]`)) + } + + return locators } async function findElement(matcher, locator) { if (locator.react) return findReact(matcher, locator) if (locator.vue) return findVue(matcher, locator) if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) + locator = new Locator(locator, 'css') return matcher.locator(buildLocatorString(locator)).first() @@ -3764,9 +4086,7 @@ async function targetCreatedHandler(page) { if (this.options.windowSize && this.options.windowSize.indexOf('x') > 0 && this._getType() === 'Browser') { try { await page.setViewportSize(parseWindowSize(this.options.windowSize)) - } catch (err) { - this.debug('Target can be already closed, ignoring...') - } + } catch (err) {} } } diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 0b417d768..35115ab00 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -634,9 +634,11 @@ class Puppeteer extends Helper { return } - const els = await this._locate(locator) - assertElementExists(els, locator) - this.context = els[0] + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element for within context') + } + this.context = el this.withinLocator = new Locator(locator) } @@ -730,11 +732,13 @@ class Puppeteer extends Helper { * {{ react }} */ async moveCursorTo(locator, offsetX = 0, offsetY = 0) { - const els = await this._locate(locator) - assertElementExists(els, locator) + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to move cursor to') + } // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates - const { x, y } = await getClickablePoint(els[0]) + const { x, y } = await getClickablePoint(el) await this.page.mouse.move(x + offsetX, y + offsetY) return this._waitForAction() } @@ -744,9 +748,10 @@ class Puppeteer extends Helper { * */ async focus(locator) { - const els = await this._locate(locator) - assertElementExists(els, locator, 'Element to focus') - const el = els[0] + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to focus') + } await el.click() await el.focus() @@ -758,10 +763,12 @@ class Puppeteer extends Helper { * */ async blur(locator) { - const els = await this._locate(locator) - assertElementExists(els, locator, 'Element to blur') + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to blur') + } - await blurElement(els[0], this.page) + await blurElement(el, this.page) return this._waitForAction() } @@ -810,11 +817,12 @@ class Puppeteer extends Helper { } if (locator) { - const els = await this._locate(locator) - assertElementExists(els, locator, 'Element') - const el = els[0] + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to scroll into view') + } await el.evaluate(el => el.scrollIntoView()) - const elementCoordinates = await getClickablePoint(els[0]) + const elementCoordinates = await getClickablePoint(el) await this.executeScript((x, y) => window.scrollBy(x, y), elementCoordinates.x + offsetX, elementCoordinates.y + offsetY) } else { await this.executeScript((x, y) => window.scrollTo(x, y), offsetX, offsetY) @@ -882,6 +890,21 @@ class Puppeteer extends Helper { return findElements.call(this, context, locator) } + /** + * Get single element by different locator types, including strict locator + * Should be used in custom helpers: + * + * ```js + * const element = await this.helpers['Puppeteer']._locateElement({name: 'password'}); + * ``` + * + * {{ react }} + */ + async _locateElement(locator) { + const context = await this.context + return findElement.call(this, context, locator) + } + /** * Find a checkbox by providing human-readable text: * NOTE: Assumes the checkable element exists @@ -893,7 +916,9 @@ class Puppeteer extends Helper { async _locateCheckable(locator, providedContext = null) { const context = providedContext || (await this._getContext()) const els = await findCheckable.call(this, locator, context) - assertElementExists(els[0], locator, 'Checkbox or radio') + if (!els || els.length === 0) { + throw new ElementNotFound(locator, 'Checkbox or radio') + } return els[0] } @@ -2124,10 +2149,12 @@ class Puppeteer extends Helper { * {{> waitForClickable }} */ async waitForClickable(locator, waitTimeout) { - const els = await this._locate(locator) - assertElementExists(els, locator) + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to wait for clickable') + } - return this.waitForFunction(isElementClickable, [els[0]], waitTimeout).catch(async e => { + return this.waitForFunction(isElementClickable, [el], waitTimeout).catch(async e => { if (/Waiting failed/i.test(e.message) || /failed: timeout/i.test(e.message)) { throw new Error(`element ${new Locator(locator).toString()} still not clickable after ${waitTimeout || this.options.waitForTimeout / 1000} sec`) } else { @@ -2701,9 +2728,18 @@ class Puppeteer extends Helper { module.exports = Puppeteer +/** + * Find elements using Puppeteer's native element discovery methods + * Note: Unlike Playwright, Puppeteer's Locator API doesn't have .all() method for multiple elements + * @param {Object} matcher - Puppeteer context to search within + * @param {Object|string} locator - Locator specification + * @returns {Promise} Array of ElementHandle objects + */ async function findElements(matcher, locator) { if (locator.react) return findReactElements.call(this, locator) locator = new Locator(locator, 'css') + + // Use proven legacy approach - Puppeteer Locator API doesn't have .all() method if (!locator.isXPath()) return matcher.$$(locator.simplify()) // puppeteer version < 19.4.0 is no longer supported. This one is backward support. if (puppeteer.default?.defaultBrowserRevision) { @@ -2712,6 +2748,31 @@ async function findElements(matcher, locator) { return matcher.$x(locator.value) } +/** + * Find a single element using Puppeteer's native element discovery methods + * Note: Puppeteer Locator API doesn't have .first() method like Playwright + * @param {Object} matcher - Puppeteer context to search within + * @param {Object|string} locator - Locator specification + * @returns {Promise} Single ElementHandle object + */ +async function findElement(matcher, locator) { + if (locator.react) return findReactElements.call(this, locator) + locator = new Locator(locator, 'css') + + // Use proven legacy approach - Puppeteer Locator API doesn't have .first() method + if (!locator.isXPath()) { + const elements = await matcher.$$(locator.simplify()) + return elements[0] + } + // puppeteer version < 19.4.0 is no longer supported. This one is backward support. + if (puppeteer.default?.defaultBrowserRevision) { + const elements = await matcher.$$(`xpath/${locator.value}`) + return elements[0] + } + const elements = await matcher.$x(locator.value) + return elements[0] +} + async function proceedClick(locator, context = null, options = {}) { let matcher = await this.context if (context) { @@ -2857,15 +2918,19 @@ async function findFields(locator) { } async function proceedDragAndDrop(sourceLocator, destinationLocator) { - const src = await this._locate(sourceLocator) - assertElementExists(src, sourceLocator, 'Source Element') + const src = await this._locateElement(sourceLocator) + if (!src) { + throw new ElementNotFound(sourceLocator, 'Source Element') + } - const dst = await this._locate(destinationLocator) - assertElementExists(dst, destinationLocator, 'Destination Element') + const dst = await this._locateElement(destinationLocator) + if (!dst) { + throw new ElementNotFound(destinationLocator, 'Destination Element') + } - // Note: Using public api .getClickablePoint becaues the .BoundingBox does not take into account iframe offsets - const dragSource = await getClickablePoint(src[0]) - const dragDestination = await getClickablePoint(dst[0]) + // Note: Using public api .getClickablePoint because the .BoundingBox does not take into account iframe offsets + const dragSource = await getClickablePoint(src) + const dragDestination = await getClickablePoint(dst) // Drag start point await this.page.mouse.move(dragSource.x, dragSource.y, { steps: 5 }) diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 69793ab1c..cac9577ec 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -995,7 +995,7 @@ class WebDriver extends Helper { * {{ react }} */ async click(locator, context = null) { - const clickMethod = this.browser.isMobile && !this.browser.isW3C ? 'touchClick' : 'elementClick' + const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick' const locateFn = prepareLocateFn.call(this, context) const res = await findClickable.call(this, locator, locateFn) @@ -1214,7 +1214,7 @@ class WebDriver extends Helper { * {{> checkOption }} */ async checkOption(field, context = null) { - const clickMethod = this.browser.isMobile && !this.browser.isW3C ? 'touchClick' : 'elementClick' + const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick' const locateFn = prepareLocateFn.call(this, context) const res = await findCheckable.call(this, field, locateFn) @@ -1234,7 +1234,7 @@ class WebDriver extends Helper { * {{> uncheckOption }} */ async uncheckOption(field, context = null) { - const clickMethod = this.browser.isMobile && !this.browser.isW3C ? 'touchClick' : 'elementClick' + const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick' const locateFn = prepareLocateFn.call(this, context) const res = await findCheckable.call(this, field, locateFn) diff --git a/lib/listener/retryEnhancer.js b/lib/listener/retryEnhancer.js new file mode 100644 index 000000000..d53effca8 --- /dev/null +++ b/lib/listener/retryEnhancer.js @@ -0,0 +1,85 @@ +const event = require('../event') +const { enhanceMochaTest } = require('../mocha/test') + +/** + * Enhance retried tests by copying CodeceptJS-specific properties from the original test + * This fixes the issue where Mocha's shallow clone during retries loses CodeceptJS properties + */ +module.exports = function () { + event.dispatcher.on(event.test.before, test => { + // Check if this test is a retry (has a reference to the original test) + const originalTest = test.retriedTest && test.retriedTest() + + if (originalTest) { + // This is a retried test - copy CodeceptJS-specific properties from the original + copyCodeceptJSProperties(originalTest, test) + + // Ensure the test is enhanced with CodeceptJS functionality + enhanceMochaTest(test) + } + }) +} + +/** + * Copy CodeceptJS-specific properties from the original test to the retried test + * @param {CodeceptJS.Test} originalTest - The original test object + * @param {CodeceptJS.Test} retriedTest - The retried test object + */ +function copyCodeceptJSProperties(originalTest, retriedTest) { + // Copy CodeceptJS-specific properties + if (originalTest.opts !== undefined) { + retriedTest.opts = originalTest.opts ? { ...originalTest.opts } : {} + } + + if (originalTest.tags !== undefined) { + retriedTest.tags = originalTest.tags ? [...originalTest.tags] : [] + } + + if (originalTest.notes !== undefined) { + retriedTest.notes = originalTest.notes ? [...originalTest.notes] : [] + } + + if (originalTest.meta !== undefined) { + retriedTest.meta = originalTest.meta ? { ...originalTest.meta } : {} + } + + if (originalTest.artifacts !== undefined) { + retriedTest.artifacts = originalTest.artifacts ? [...originalTest.artifacts] : [] + } + + if (originalTest.steps !== undefined) { + retriedTest.steps = originalTest.steps ? [...originalTest.steps] : [] + } + + if (originalTest.config !== undefined) { + retriedTest.config = originalTest.config ? { ...originalTest.config } : {} + } + + if (originalTest.inject !== undefined) { + retriedTest.inject = originalTest.inject ? { ...originalTest.inject } : {} + } + + // Copy methods that might be missing + if (originalTest.addNote && !retriedTest.addNote) { + retriedTest.addNote = function (type, note) { + this.notes = this.notes || [] + this.notes.push({ type, text: note }) + } + } + + if (originalTest.applyOptions && !retriedTest.applyOptions) { + retriedTest.applyOptions = originalTest.applyOptions.bind(retriedTest) + } + + if (originalTest.simplify && !retriedTest.simplify) { + retriedTest.simplify = originalTest.simplify.bind(retriedTest) + } + + // Preserve the uid if it exists + if (originalTest.uid !== undefined) { + retriedTest.uid = originalTest.uid + } + + // Mark as enhanced + retriedTest.codeceptjs = true +} diff --git a/lib/output.js b/lib/output.js index a551174ae..b9e0e0d95 100644 --- a/lib/output.js +++ b/lib/output.js @@ -1,6 +1,6 @@ const colors = require('chalk') const figures = require('figures') -const { maskSensitiveData } = require('invisi-data') +const { maskData, shouldMaskData, getMaskConfig } = require('./utils/mask_data') const styles = { error: colors.bgRed.white.bold, @@ -59,7 +59,7 @@ module.exports = { * @param {string} msg */ debug(msg) { - const _msg = isMaskedData() ? maskSensitiveData(msg) : msg + const _msg = shouldMaskData() ? maskData(msg, getMaskConfig()) : msg if (outputLevel >= 2) { print(' '.repeat(this.stepShift), styles.debug(`${figures.pointerSmall} ${_msg}`)) } @@ -70,7 +70,7 @@ module.exports = { * @param {string} msg */ log(msg) { - const _msg = isMaskedData() ? maskSensitiveData(msg) : msg + const _msg = shouldMaskData() ? maskData(msg, getMaskConfig()) : msg if (outputLevel >= 3) { print(' '.repeat(this.stepShift), styles.log(truncate(` ${_msg}`, this.spaceShift))) } @@ -81,7 +81,8 @@ module.exports = { * @param {string} msg */ error(msg) { - print(styles.error(msg)) + const _msg = shouldMaskData() ? maskData(msg, getMaskConfig()) : msg + print(styles.error(_msg)) }, /** @@ -89,7 +90,8 @@ module.exports = { * @param {string} msg */ success(msg) { - print(styles.success(msg)) + const _msg = shouldMaskData() ? maskData(msg, getMaskConfig()) : msg + print(styles.success(_msg)) }, /** @@ -124,7 +126,7 @@ module.exports = { stepLine += colors.grey(step.comment.split('\n').join('\n' + ' '.repeat(4))) } - const _stepLine = isMaskedData() ? maskSensitiveData(stepLine) : stepLine + const _stepLine = shouldMaskData() ? maskData(stepLine, getMaskConfig()) : stepLine print(' '.repeat(this.stepShift), truncate(_stepLine, this.spaceShift)) }, @@ -278,7 +280,3 @@ function truncate(msg, gap = 0) { } return msg } - -function isMaskedData() { - return global.maskSensitiveData === true || false -} diff --git a/lib/plugin/htmlReporter.js b/lib/plugin/htmlReporter.js new file mode 100644 index 000000000..5dca442ca --- /dev/null +++ b/lib/plugin/htmlReporter.js @@ -0,0 +1,2955 @@ +const fs = require('fs') +const path = require('path') +const mkdirp = require('mkdirp') +const crypto = require('crypto') +const { threadId } = require('worker_threads') +const { template } = require('../utils') +const { getMachineInfo } = require('../command/info') + +const event = require('../event') +const output = require('../output') +const Codecept = require('../codecept') + +const defaultConfig = { + output: global.output_dir || './output', + reportFileName: 'report.html', + includeArtifacts: true, + showSteps: true, + showSkipped: true, + showMetadata: true, + showTags: true, + showRetries: true, + exportStats: false, + exportStatsPath: './stats.json', + keepHistory: false, + historyPath: './test-history.json', + maxHistoryEntries: 50, +} + +/** + * HTML Reporter Plugin for CodeceptJS + * + * Generates comprehensive HTML reports showing: + * - Test statistics + * - Feature/Scenario details + * - Individual step results + * - Test artifacts (screenshots, etc.) + * + * ## Configuration + * + * ```js + * "plugins": { + * "htmlReporter": { + * "enabled": true, + * "output": "./output", + * "reportFileName": "report.html", + * "includeArtifacts": true, + * "showSteps": true, + * "showSkipped": true, + * "showMetadata": true, + * "showTags": true, + * "showRetries": true, + * "exportStats": false, + * "exportStatsPath": "./stats.json", + * "keepHistory": false, + * "historyPath": "./test-history.json", + * "maxHistoryEntries": 50 + * } + * } + * ``` + */ +module.exports = function (config) { + const options = { ...defaultConfig, ...config } + let reportData = { + stats: {}, + tests: [], + failures: [], + hooks: [], + startTime: null, + endTime: null, + retries: [], + config: options, + } + let currentTestSteps = [] + let currentTestHooks = [] + let currentBddSteps = [] // Track BDD/Gherkin steps + let testRetryAttempts = new Map() // Track retry attempts per test + let currentSuite = null // Track current suite for BDD detection + + // Initialize report directory + const reportDir = options.output ? path.resolve(global.codecept_dir, options.output) : path.resolve(global.output_dir || './output') + mkdirp.sync(reportDir) + + // Track overall test execution + event.dispatcher.on(event.all.before, () => { + reportData.startTime = new Date() + output.plugin('htmlReporter', 'Starting HTML report generation...') + }) + + // Track test start to initialize steps and hooks collection + event.dispatcher.on(event.test.before, test => { + currentTestSteps = [] + currentTestHooks = [] + currentBddSteps = [] + + // Track current suite for BDD detection + currentSuite = test.parent + + // Enhanced retry detection with priority-based approach + const testId = generateTestId(test) + + // Only set retry count if not already set, using priority order + if (!testRetryAttempts.has(testId)) { + // Method 1: Check retryNum property (most reliable) + if (test.retryNum && test.retryNum > 0) { + testRetryAttempts.set(testId, test.retryNum) + output.print(`HTML Reporter: Retry count detected (retryNum) for ${test.title}, attempts: ${test.retryNum}`) + } + // Method 2: Check currentRetry property + else if (test.currentRetry && test.currentRetry > 0) { + testRetryAttempts.set(testId, test.currentRetry) + output.print(`HTML Reporter: Retry count detected (currentRetry) for ${test.title}, attempts: ${test.currentRetry}`) + } + // Method 3: Check if this is a retried test + else if (test.retriedTest && test.retriedTest()) { + const originalTest = test.retriedTest() + const originalTestId = generateTestId(originalTest) + if (!testRetryAttempts.has(originalTestId)) { + testRetryAttempts.set(originalTestId, 1) // Start with 1 retry + } else { + testRetryAttempts.set(originalTestId, testRetryAttempts.get(originalTestId) + 1) + } + output.print(`HTML Reporter: Retry detected (retriedTest) for ${originalTest.title}, attempts: ${testRetryAttempts.get(originalTestId)}`) + } + // Method 4: Check if test has been seen before (indicating a retry) + else if (reportData.tests.some(t => t.id === testId)) { + testRetryAttempts.set(testId, 1) // First retry detected + output.print(`HTML Reporter: Retry detected (duplicate test) for ${test.title}, attempts: 1`) + } + } + }) + + // Collect step information + event.dispatcher.on(event.step.started, step => { + step.htmlReporterStartTime = Date.now() + }) + + event.dispatcher.on(event.step.finished, step => { + if (step.htmlReporterStartTime) { + step.duration = Date.now() - step.htmlReporterStartTime + } + currentTestSteps.push({ + name: step.name, + actor: step.actor, + args: step.args || [], + status: step.failed ? 'failed' : 'success', + duration: step.duration || 0, + }) + }) + + // Collect hook information + event.dispatcher.on(event.hook.started, hook => { + hook.htmlReporterStartTime = Date.now() + }) + + event.dispatcher.on(event.hook.finished, hook => { + if (hook.htmlReporterStartTime) { + hook.duration = Date.now() - hook.htmlReporterStartTime + } + const hookInfo = { + title: hook.title, + type: hook.type || 'unknown', // before, after, beforeSuite, afterSuite + status: hook.err ? 'failed' : 'passed', + duration: hook.duration || 0, + error: hook.err ? hook.err.message : null, + } + currentTestHooks.push(hookInfo) + reportData.hooks.push(hookInfo) + }) + + // Collect BDD/Gherkin step information + event.dispatcher.on(event.bddStep.started, step => { + step.htmlReporterStartTime = Date.now() + }) + + event.dispatcher.on(event.bddStep.finished, step => { + if (step.htmlReporterStartTime) { + step.duration = Date.now() - step.htmlReporterStartTime + } + currentBddSteps.push({ + keyword: step.actor || 'Given', + text: step.name, + status: step.failed ? 'failed' : 'success', + duration: step.duration || 0, + comment: step.comment, + }) + }) + + // Collect test results + event.dispatcher.on(event.test.finished, test => { + const testId = generateTestId(test) + let retryAttempts = testRetryAttempts.get(testId) || 0 + + // Additional retry detection in test.finished event + // Check if this test has retry indicators we might have missed + if (retryAttempts === 0) { + if (test.retryNum && test.retryNum > 0) { + retryAttempts = test.retryNum + testRetryAttempts.set(testId, retryAttempts) + output.print(`HTML Reporter: Late retry detection (retryNum) for ${test.title}, attempts: ${retryAttempts}`) + } else if (test.currentRetry && test.currentRetry > 0) { + retryAttempts = test.currentRetry + testRetryAttempts.set(testId, retryAttempts) + output.print(`HTML Reporter: Late retry detection (currentRetry) for ${test.title}, attempts: ${retryAttempts}`) + } else if (test._retries && test._retries > 0) { + retryAttempts = test._retries + testRetryAttempts.set(testId, retryAttempts) + output.print(`HTML Reporter: Late retry detection (_retries) for ${test.title}, attempts: ${retryAttempts}`) + } + } + + // Debug logging + output.print(`HTML Reporter: Test finished - ${test.title}, State: ${test.state}, Retries: ${retryAttempts}`) + + // Detect if this is a BDD/Gherkin test + const isBddTest = isBddGherkinTest(test, currentSuite) + const steps = isBddTest ? currentBddSteps : currentTestSteps + const featureInfo = isBddTest ? getBddFeatureInfo(test, currentSuite) : null + + // Check if this test already exists in reportData.tests (from a previous retry) + const existingTestIndex = reportData.tests.findIndex(t => t.id === testId) + const hasFailedBefore = existingTestIndex >= 0 && reportData.tests[existingTestIndex].state === 'failed' + const currentlyFailed = test.state === 'failed' + + // Debug artifacts collection (but don't process them yet - screenshots may not be ready) + output.print(`HTML Reporter: Test ${test.title} artifacts at test.finished: ${JSON.stringify(test.artifacts)}`) + + const testData = { + ...test, + id: testId, + duration: test.duration || 0, + steps: [...steps], // Copy the steps (BDD or regular) + hooks: [...currentTestHooks], // Copy the hooks + artifacts: test.artifacts || [], // Keep original artifacts for now + tags: test.tags || [], + meta: test.meta || {}, + opts: test.opts || {}, + notes: test.notes || [], + retryAttempts: currentlyFailed || hasFailedBefore ? retryAttempts : 0, // Only show retries for failed tests + uid: test.uid, + isBdd: isBddTest, + feature: featureInfo, + } + + if (existingTestIndex >= 0) { + // Update existing test with final result (including failed state) + reportData.tests[existingTestIndex] = testData + output.print(`HTML Reporter: Updated existing test - ${test.title}, Final state: ${test.state}`) + } else { + // Add new test + reportData.tests.push(testData) + output.print(`HTML Reporter: Added new test - ${test.title}, State: ${test.state}`) + } + + // Track retry information - only add if there were actual retries AND the test failed at some point + const existingRetryIndex = reportData.retries.findIndex(r => r.testId === testId) + + // Only track retries if: + // 1. There are retry attempts detected AND (test failed now OR failed before) + // 2. OR there's an existing retry record (meaning it failed before) + if ((retryAttempts > 0 && (currentlyFailed || hasFailedBefore)) || existingRetryIndex >= 0) { + // If no retry attempts detected but we have an existing retry record, increment it + if (retryAttempts === 0 && existingRetryIndex >= 0) { + retryAttempts = reportData.retries[existingRetryIndex].attempts + 1 + testRetryAttempts.set(testId, retryAttempts) + output.print(`HTML Reporter: Incremented retry count for duplicate test ${test.title}, attempts: ${retryAttempts}`) + } + + // Remove existing retry info for this test and add updated one + reportData.retries = reportData.retries.filter(r => r.testId !== testId) + reportData.retries.push({ + testId: testId, + testTitle: test.title, + attempts: retryAttempts, + finalState: test.state, + duration: test.duration || 0, + }) + output.print(`HTML Reporter: Added retry info for ${test.title}, attempts: ${retryAttempts}, state: ${test.state}`) + } + + // Fallback: If this test already exists and either failed before or is failing now, it's a retry + else if (existingTestIndex >= 0 && (hasFailedBefore || currentlyFailed)) { + const fallbackAttempts = 1 + testRetryAttempts.set(testId, fallbackAttempts) + reportData.retries.push({ + testId: testId, + testTitle: test.title, + attempts: fallbackAttempts, + finalState: test.state, + duration: test.duration || 0, + }) + output.print(`HTML Reporter: Fallback retry detection for failed test ${test.title}, attempts: ${fallbackAttempts}`) + } + }) + + // Generate final report + event.dispatcher.on(event.all.result, result => { + reportData.endTime = new Date() + reportData.duration = reportData.endTime - reportData.startTime + + // Process artifacts now that all async tasks (including screenshots) are complete + output.print(`HTML Reporter: Processing artifacts for ${reportData.tests.length} tests after all async tasks complete`) + + reportData.tests.forEach(test => { + const originalArtifacts = test.artifacts + let collectedArtifacts = [] + + output.print(`HTML Reporter: Processing test "${test.title}" (ID: ${test.id})`) + output.print(`HTML Reporter: Test ${test.title} final artifacts: ${JSON.stringify(originalArtifacts)}`) + + if (originalArtifacts) { + if (Array.isArray(originalArtifacts)) { + collectedArtifacts = originalArtifacts + output.print(`HTML Reporter: Using array artifacts: ${collectedArtifacts.length} items`) + } else if (typeof originalArtifacts === 'object') { + // Convert object properties to array (screenshotOnFail plugin format) + collectedArtifacts = Object.values(originalArtifacts).filter(artifact => artifact) + output.print(`HTML Reporter: Converted artifacts object to array: ${collectedArtifacts.length} items`) + output.print(`HTML Reporter: Converted artifacts: ${JSON.stringify(collectedArtifacts)}`) + } + } + + // Only use filesystem fallback if no artifacts found from screenshotOnFail plugin + if (collectedArtifacts.length === 0 && test.state === 'failed') { + output.print(`HTML Reporter: No artifacts from plugin, trying filesystem for test "${test.title}"`) + collectedArtifacts = collectScreenshotsFromFilesystem(test, test.id) + output.print(`HTML Reporter: Collected ${collectedArtifacts.length} screenshots from filesystem for failed test "${test.title}"`) + if (collectedArtifacts.length > 0) { + output.print(`HTML Reporter: Filesystem screenshots for "${test.title}": ${JSON.stringify(collectedArtifacts)}`) + } + } + + // Update test with processed artifacts + test.artifacts = collectedArtifacts + output.print(`HTML Reporter: Final artifacts for "${test.title}": ${JSON.stringify(test.artifacts)}`) + }) + + // Calculate stats from our collected test data instead of using result.stats + const passedTests = reportData.tests.filter(t => t.state === 'passed').length + const failedTests = reportData.tests.filter(t => t.state === 'failed').length + const pendingTests = reportData.tests.filter(t => t.state === 'pending').length + const skippedTests = reportData.tests.filter(t => t.state === 'skipped').length + + // Populate failures from our collected test data with enhanced details + reportData.failures = reportData.tests + .filter(t => t.state === 'failed') + .map(t => { + const testName = t.title || 'Unknown Test' + const featureName = t.parent?.title || 'Unknown Feature' + + if (t.err) { + const errorMessage = t.err.message || t.err.toString() || 'Test failed' + const errorStack = t.err.stack || '' + const filePath = t.file || t.parent?.file || '' + + // Create enhanced failure object with test details + return { + testName: testName, + featureName: featureName, + message: errorMessage, + stack: errorStack, + filePath: filePath, + toString: () => `${testName} (${featureName})\n${errorMessage}\n${errorStack}`.trim(), + } + } + + return { + testName: testName, + featureName: featureName, + message: `Test failed: ${testName}`, + stack: '', + filePath: t.file || t.parent?.file || '', + toString: () => `${testName} (${featureName})\nTest failed: ${testName}`, + } + }) + + reportData.stats = { + tests: reportData.tests.length, + passes: passedTests, + failures: failedTests, + pending: pendingTests, + skipped: skippedTests, + duration: reportData.duration, + failedHooks: result.stats?.failedHooks || 0, + } + + // Debug logging for final stats + output.print(`HTML Reporter: Calculated stats - Tests: ${reportData.stats.tests}, Passes: ${reportData.stats.passes}, Failures: ${reportData.stats.failures}`) + output.print(`HTML Reporter: Collected ${reportData.tests.length} tests in reportData`) + output.print(`HTML Reporter: Failures array has ${reportData.failures.length} items`) + output.print(`HTML Reporter: Retries array has ${reportData.retries.length} items`) + output.print(`HTML Reporter: testRetryAttempts Map size: ${testRetryAttempts.size}`) + + // Log retry attempts map contents + for (const [testId, attempts] of testRetryAttempts.entries()) { + output.print(`HTML Reporter: testRetryAttempts - ${testId}: ${attempts} attempts`) + } + + reportData.tests.forEach(test => { + output.print(`HTML Reporter: Test in reportData - ${test.title}, State: ${test.state}, Retries: ${test.retryAttempts}`) + }) + + // Check if running with workers + if (process.env.RUNS_WITH_WORKERS) { + // In worker mode, save results to a JSON file for later consolidation + const workerId = threadId + const jsonFileName = `worker-${workerId}-results.json` + const jsonPath = path.join(reportDir, jsonFileName) + + try { + // Always overwrite the file with the latest complete data from this worker + // This prevents double-counting when the event is triggered multiple times + fs.writeFileSync(jsonPath, safeJsonStringify(reportData)) + output.print(`HTML Reporter: Generated worker JSON results: ${jsonFileName}`) + } catch (error) { + output.print(`HTML Reporter: Failed to write worker JSON: ${error.message}`) + } + return + } + + // Single process mode - generate report normally + generateHtmlReport(reportData, options) + + // Export stats if configured + if (options.exportStats) { + exportTestStats(reportData, options) + } + + // Save history if configured + if (options.keepHistory) { + saveTestHistory(reportData, options) + } + }) + + // Handle worker consolidation after all workers complete + event.dispatcher.on(event.workers.result, async result => { + if (process.env.RUNS_WITH_WORKERS) { + // Only run consolidation in main process + await consolidateWorkerJsonResults(options) + } + }) + + /** + * Safely serialize data to JSON, handling circular references + */ + function safeJsonStringify(data) { + const seen = new WeakSet() + return JSON.stringify( + data, + (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + // For error objects, try to extract useful information instead of "[Circular Reference]" + if (key === 'err' || key === 'error') { + return { + message: value.message || 'Error occurred', + stack: value.stack || '', + name: value.name || 'Error', + } + } + // Skip circular references for other objects + return undefined + } + seen.add(value) + + // Special handling for error objects to preserve important properties + if (value instanceof Error || (value.message && value.stack)) { + return { + message: value.message || '', + stack: value.stack || '', + name: value.name || 'Error', + toString: () => value.message || 'Error occurred', + } + } + } + return value + }, + 2, + ) + } + + function generateTestId(test) { + return crypto + .createHash('sha256') + .update(`${test.parent?.title || 'unknown'}_${test.title}`) + .digest('hex') + .substring(0, 8) + } + + function collectScreenshotsFromFilesystem(test, testId) { + const screenshots = [] + + try { + // Common screenshot locations to check + const possibleDirs = [ + reportDir, // Same as report directory + global.output_dir || './output', // Global output directory + path.resolve(global.codecept_dir || '.', 'output'), // Codecept output directory + path.resolve('.', 'output'), // Current directory output + path.resolve('.', '_output'), // Alternative output directory + path.resolve('output'), // Relative output directory + path.resolve('qa', 'output'), // QA project output directory + path.resolve('..', 'qa', 'output'), // Parent QA project output directory + ] + + // Use the exact same logic as screenshotOnFail plugin's testToFileName function + const originalTestName = test.title || 'test' + const originalFeatureName = test.parent?.title || 'feature' + + // Replicate testToFileName logic from lib/mocha/test.js + function replicateTestToFileName(testTitle) { + let fileName = testTitle + + // Slice to 100 characters first + fileName = fileName.slice(0, 100) + + // Handle data-driven tests: remove everything from '{' onwards (with 3 chars before) + if (fileName.indexOf('{') !== -1) { + fileName = fileName.substr(0, fileName.indexOf('{') - 3).trim() + } + + // Apply clearString logic from utils.js + if (fileName.endsWith('.')) { + fileName = fileName.slice(0, -1) + } + fileName = fileName + .replace(/ /g, '_') + .replace(/"/g, "'") + .replace(/\//g, '_') + .replace(//g, ')') + .replace(/:/g, '_') + .replace(/\\/g, '_') + .replace(/\|/g, '_') + .replace(/\?/g, '.') + .replace(/\*/g, '^') + .replace(/'/g, '') + + // Final slice to 100 characters + return fileName.slice(0, 100) + } + + const testName = replicateTestToFileName(originalTestName) + const featureName = replicateTestToFileName(originalFeatureName) + + output.print(`HTML Reporter: Original test title: "${originalTestName}"`) + output.print(`HTML Reporter: CodeceptJS filename: "${testName}"`) + + // Generate possible screenshot names based on CodeceptJS patterns + const possibleNames = [ + `${testName}.failed.png`, // Primary CodeceptJS screenshotOnFail pattern + `${testName}.failed.jpg`, + `${featureName}_${testName}.failed.png`, + `${featureName}_${testName}.failed.jpg`, + `Test_${testName}.failed.png`, // Alternative pattern + `Test_${testName}.failed.jpg`, + `${testName}.png`, + `${testName}.jpg`, + `${featureName}_${testName}.png`, + `${featureName}_${testName}.jpg`, + `failed_${testName}.png`, + `failed_${testName}.jpg`, + `screenshot_${testId}.png`, + `screenshot_${testId}.jpg`, + 'screenshot.png', + 'screenshot.jpg', + 'failure.png', + 'failure.jpg', + ] + + output.print(`HTML Reporter: Checking ${possibleNames.length} possible screenshot names for "${testName}"`) + + // Search for screenshots in possible directories + for (const dir of possibleDirs) { + output.print(`HTML Reporter: Checking directory: ${dir}`) + if (!fs.existsSync(dir)) { + output.print(`HTML Reporter: Directory does not exist: ${dir}`) + continue + } + + try { + const files = fs.readdirSync(dir) + output.print(`HTML Reporter: Found ${files.length} files in ${dir}`) + + // Look for exact matches first + for (const name of possibleNames) { + if (files.includes(name)) { + const fullPath = path.join(dir, name) + if (!screenshots.includes(fullPath)) { + screenshots.push(fullPath) + output.print(`HTML Reporter: Found screenshot: ${fullPath}`) + } + } + } + + // Look for screenshot files that are specifically for this test + // Be more strict to avoid cross-test contamination + const screenshotFiles = files.filter(file => { + const lowerFile = file.toLowerCase() + const lowerTestName = testName.toLowerCase() + const lowerFeatureName = featureName.toLowerCase() + + return ( + file.match(/\.(png|jpg|jpeg|gif|webp|bmp)$/i) && + // Exact test name matches with .failed pattern (most specific) + (file === `${testName}.failed.png` || + file === `${testName}.failed.jpg` || + file === `${featureName}_${testName}.failed.png` || + file === `${featureName}_${testName}.failed.jpg` || + file === `Test_${testName}.failed.png` || + file === `Test_${testName}.failed.jpg` || + // Word boundary checks for .failed pattern + (lowerFile.includes('.failed.') && + (lowerFile.startsWith(lowerTestName + '.') || lowerFile.startsWith(lowerFeatureName + '_' + lowerTestName + '.') || lowerFile.startsWith('test_' + lowerTestName + '.')))) + ) + }) + + for (const file of screenshotFiles) { + const fullPath = path.join(dir, file) + if (!screenshots.includes(fullPath)) { + screenshots.push(fullPath) + output.print(`HTML Reporter: Found related screenshot: ${fullPath}`) + } + } + } catch (error) { + // Ignore directory read errors + output.print(`HTML Reporter: Could not read directory ${dir}: ${error.message}`) + } + } + } catch (error) { + output.print(`HTML Reporter: Error collecting screenshots: ${error.message}`) + } + + return screenshots + } + + function isBddGherkinTest(test, suite) { + // Check if the suite has BDD/Gherkin properties + return !!(suite && (suite.feature || suite.file?.endsWith('.feature'))) + } + + function getBddFeatureInfo(test, suite) { + if (!suite) return null + + return { + name: suite.feature?.name || suite.title, + description: suite.feature?.description || suite.comment || '', + language: suite.feature?.language || 'en', + tags: suite.tags || [], + file: suite.file || '', + } + } + + function exportTestStats(data, config) { + const statsPath = path.resolve(reportDir, config.exportStatsPath) + + const exportData = { + timestamp: data.endTime.toISOString(), + duration: data.duration, + stats: data.stats, + retries: data.retries, + testCount: data.tests.length, + passedTests: data.tests.filter(t => t.state === 'passed').length, + failedTests: data.tests.filter(t => t.state === 'failed').length, + pendingTests: data.tests.filter(t => t.state === 'pending').length, + tests: data.tests.map(test => ({ + id: test.id, + title: test.title, + feature: test.parent?.title || 'Unknown', + state: test.state, + duration: test.duration, + tags: test.tags, + meta: test.meta, + retryAttempts: test.retryAttempts, + uid: test.uid, + })), + } + + try { + fs.writeFileSync(statsPath, JSON.stringify(exportData, null, 2)) + output.print(`Test stats exported to: ${statsPath}`) + } catch (error) { + output.print(`Failed to export test stats: ${error.message}`) + } + } + + function saveTestHistory(data, config) { + const historyPath = path.resolve(reportDir, config.historyPath) + let history = [] + + // Load existing history + try { + if (fs.existsSync(historyPath)) { + history = JSON.parse(fs.readFileSync(historyPath, 'utf8')) + } + } catch (error) { + output.print(`Failed to load existing history: ${error.message}`) + } + + // Add current run to history + history.unshift({ + timestamp: data.endTime.toISOString(), + duration: data.duration, + stats: data.stats, + retries: data.retries.length, + testCount: data.tests.length, + }) + + // Limit history entries + if (history.length > config.maxHistoryEntries) { + history = history.slice(0, config.maxHistoryEntries) + } + + try { + fs.writeFileSync(historyPath, JSON.stringify(history, null, 2)) + output.print(`Test history saved to: ${historyPath}`) + } catch (error) { + output.print(`Failed to save test history: ${error.message}`) + } + } + + /** + * Consolidates JSON reports from multiple workers into a single HTML report + */ + async function consolidateWorkerJsonResults(config) { + const jsonFiles = fs.readdirSync(reportDir).filter(file => file.startsWith('worker-') && file.endsWith('-results.json')) + + if (jsonFiles.length === 0) { + output.print('HTML Reporter: No worker JSON results found to consolidate') + return + } + + output.print(`HTML Reporter: Found ${jsonFiles.length} worker JSON files to consolidate`) + + // Initialize consolidated data structure + const consolidatedData = { + stats: { + tests: 0, + passes: 0, + failures: 0, + pending: 0, + skipped: 0, + duration: 0, + failedHooks: 0, + }, + tests: [], + failures: [], + hooks: [], + startTime: new Date(), + endTime: new Date(), + retries: [], + duration: 0, + } + + try { + // Process each worker's JSON file + for (const jsonFile of jsonFiles) { + const jsonPath = path.join(reportDir, jsonFile) + try { + const workerData = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) + + // Merge stats + if (workerData.stats) { + consolidatedData.stats.passes += workerData.stats.passes || 0 + consolidatedData.stats.failures += workerData.stats.failures || 0 + consolidatedData.stats.tests += workerData.stats.tests || 0 + consolidatedData.stats.pending += workerData.stats.pending || 0 + consolidatedData.stats.skipped += workerData.stats.skipped || 0 + consolidatedData.stats.duration += workerData.stats.duration || 0 + consolidatedData.stats.failedHooks += workerData.stats.failedHooks || 0 + } + + // Merge tests and failures + if (workerData.tests) consolidatedData.tests.push(...workerData.tests) + if (workerData.failures) consolidatedData.failures.push(...workerData.failures) + if (workerData.hooks) consolidatedData.hooks.push(...workerData.hooks) + if (workerData.retries) consolidatedData.retries.push(...workerData.retries) + + // Update timestamps + if (workerData.startTime) { + const workerStart = new Date(workerData.startTime).getTime() + const currentStart = new Date(consolidatedData.startTime).getTime() + if (workerStart < currentStart) { + consolidatedData.startTime = workerData.startTime + } + } + + if (workerData.endTime) { + const workerEnd = new Date(workerData.endTime).getTime() + const currentEnd = new Date(consolidatedData.endTime).getTime() + if (workerEnd > currentEnd) { + consolidatedData.endTime = workerData.endTime + } + } + + // Update duration + if (workerData.duration) { + consolidatedData.duration = Math.max(consolidatedData.duration, workerData.duration) + } + + // Clean up the worker JSON file + try { + fs.unlinkSync(jsonPath) + } catch (error) { + output.print(`Failed to delete worker JSON file ${jsonFile}: ${error.message}`) + } + } catch (error) { + output.print(`Failed to process worker JSON file ${jsonFile}: ${error.message}`) + } + } + + // Generate the final HTML report + generateHtmlReport(consolidatedData, config) + + // Export stats if configured + if (config.exportStats) { + exportTestStats(consolidatedData, config) + } + + // Save history if configured + if (config.keepHistory) { + saveTestHistory(consolidatedData, config) + } + + output.print(`HTML Reporter: Successfully consolidated ${jsonFiles.length} worker reports`) + } catch (error) { + output.print(`HTML Reporter: Failed to consolidate worker reports: ${error.message}`) + } + } + + async function generateHtmlReport(data, config) { + const reportPath = path.join(reportDir, config.reportFileName) + + // Load history if available + let history = [] + if (config.keepHistory) { + const historyPath = path.resolve(reportDir, config.historyPath) + try { + if (fs.existsSync(historyPath)) { + history = JSON.parse(fs.readFileSync(historyPath, 'utf8')) // Show all available history + } + } catch (error) { + output.print(`Failed to load history for report: ${error.message}`) + } + + // Add current run to history for chart display (before saving to file) + const currentRun = { + timestamp: data.endTime.toISOString(), + duration: data.duration, + stats: data.stats, + retries: data.retries.length, + testCount: data.tests.length, + } + history.unshift(currentRun) + + // Limit history entries for chart display + if (history.length > config.maxHistoryEntries) { + history = history.slice(0, config.maxHistoryEntries) + } + } + + // Get system information + const systemInfo = await getMachineInfo() + + const html = template(getHtmlTemplate(), { + title: `CodeceptJS Test Report v${Codecept.version()}`, + timestamp: data.endTime.toISOString(), + duration: formatDuration(data.duration), + stats: JSON.stringify(data.stats), + history: JSON.stringify(history), + statsHtml: generateStatsHtml(data.stats), + testsHtml: generateTestsHtml(data.tests, config), + retriesHtml: config.showRetries ? generateRetriesHtml(data.retries) : '', + cssStyles: getCssStyles(), + jsScripts: getJsScripts(), + showRetries: config.showRetries ? 'block' : 'none', + showHistory: config.keepHistory && history.length > 0 ? 'block' : 'none', + codeceptVersion: Codecept.version(), + systemInfoHtml: generateSystemInfoHtml(systemInfo), + }) + + fs.writeFileSync(reportPath, html) + output.print(`HTML Report saved to: ${reportPath}`) + } + + function generateStatsHtml(stats) { + const passed = stats.passes || 0 + const failed = stats.failures || 0 + const pending = stats.pending || 0 + const total = stats.tests || 0 + + return ` +
+
+

Total

+ ${total} +
+
+

Passed

+ ${passed} +
+
+

Failed

+ ${failed} +
+
+

Pending

+ ${pending} +
+
+
+ + +
+ ` + } + + function generateTestsHtml(tests, config) { + if (!tests || tests.length === 0) { + return '

No tests found.

' + } + + return tests + .map(test => { + const statusClass = test.state || 'unknown' + const feature = test.isBdd && test.feature ? test.feature.name : test.parent?.title || 'Unknown Feature' + const steps = config.showSteps && test.steps ? (test.isBdd ? generateBddStepsHtml(test.steps) : generateStepsHtml(test.steps)) : '' + const featureDetails = test.isBdd && test.feature ? generateBddFeatureHtml(test.feature) : '' + const hooks = test.hooks && test.hooks.length > 0 ? generateHooksHtml(test.hooks) : '' + const artifacts = config.includeArtifacts && test.artifacts ? generateArtifactsHtml(test.artifacts, test.state === 'failed') : '' + const metadata = config.showMetadata && (test.meta || test.opts) ? generateMetadataHtml(test.meta, test.opts) : '' + const tags = config.showTags && test.tags && test.tags.length > 0 ? generateTagsHtml(test.tags) : '' + const retries = config.showRetries && test.retryAttempts > 0 ? generateTestRetryHtml(test.retryAttempts) : '' + const notes = test.notes && test.notes.length > 0 ? generateNotesHtml(test.notes) : '' + + return ` +
+
+ +
+

${test.isBdd ? `Scenario: ${test.title}` : test.title}

+
+ ${test.isBdd ? 'Feature: ' : ''}${feature} + ${test.uid ? `${test.uid}` : ''} + ${formatDuration(test.duration)} + ${test.retryAttempts > 0 ? `${test.retryAttempts} retries` : ''} + ${test.isBdd ? 'Gherkin' : ''} +
+
+
+
+ ${test.err ? `
${escapeHtml(getErrorMessage(test))}
` : ''} + ${featureDetails} + ${tags} + ${metadata} + ${retries} + ${notes} + ${hooks} + ${steps} + ${artifacts} +
+
+ ` + }) + .join('') + } + + function generateStepsHtml(steps) { + if (!steps || steps.length === 0) return '' + + const stepsHtml = steps + .map(step => { + const statusClass = step.status || 'unknown' + const args = step.args ? step.args.map(arg => JSON.stringify(arg)).join(', ') : '' + const stepName = step.name || 'unknown step' + const actor = step.actor || 'I' + + return ` +
+ + ${actor}.${stepName}(${args}) + ${formatDuration(step.duration)} +
+ ` + }) + .join('') + + return ` +
+

Steps:

+
${stepsHtml}
+
+ ` + } + + function generateBddStepsHtml(steps) { + if (!steps || steps.length === 0) return '' + + const stepsHtml = steps + .map(step => { + const statusClass = step.status || 'unknown' + const keyword = step.keyword || 'Given' + const text = step.text || '' + const comment = step.comment ? `
${escapeHtml(step.comment)}
` : '' + + return ` +
+ + ${keyword} + ${escapeHtml(text)} + ${formatDuration(step.duration)} + ${comment} +
+ ` + }) + .join('') + + return ` +
+

Scenario Steps:

+
${stepsHtml}
+
+ ` + } + + function generateBddFeatureHtml(feature) { + if (!feature) return '' + + const description = feature.description ? `
${escapeHtml(feature.description)}
` : '' + const featureTags = feature.tags && feature.tags.length > 0 ? `
${feature.tags.map(tag => `${escapeHtml(tag)}`).join('')}
` : '' + + return ` +
+

Feature Information:

+
+
Feature: ${escapeHtml(feature.name)}
+ ${description} + ${featureTags} + ${feature.file ? `
File: ${escapeHtml(feature.file)}
` : ''} +
+
+ ` + } + + function generateHooksHtml(hooks) { + if (!hooks || hooks.length === 0) return '' + + const hooksHtml = hooks + .map(hook => { + const statusClass = hook.status || 'unknown' + const hookType = hook.type || 'hook' + const hookTitle = hook.title || `${hookType} hook` + + return ` +
+ + ${hookType}: ${hookTitle} + ${formatDuration(hook.duration)} + ${hook.error ? `
${escapeHtml(hook.error)}
` : ''} +
+ ` + }) + .join('') + + return ` +
+

Hooks:

+
${hooksHtml}
+
+ ` + } + + function generateMetadataHtml(meta, opts) { + const allMeta = { ...(opts || {}), ...(meta || {}) } + if (!allMeta || Object.keys(allMeta).length === 0) return '' + + const metaHtml = Object.entries(allMeta) + .filter(([key, value]) => value !== undefined && value !== null) + .map(([key, value]) => { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value.toString() + return `
${escapeHtml(key)}: ${escapeHtml(displayValue)}
` + }) + .join('') + + return ` + + ` + } + + function generateTagsHtml(tags) { + if (!tags || tags.length === 0) return '' + + const tagsHtml = tags.map(tag => `${escapeHtml(tag)}`).join('') + + return ` +
+

Tags:

+
${tagsHtml}
+
+ ` + } + + function generateNotesHtml(notes) { + if (!notes || notes.length === 0) return '' + + const notesHtml = notes.map(note => `
${note.type || 'info'}: ${escapeHtml(note.text)}
`).join('') + + return ` +
+

Notes:

+
${notesHtml}
+
+ ` + } + + function generateTestRetryHtml(retryAttempts) { + return ` +
+

Retry Information:

+
+ Total retry attempts: ${retryAttempts} +
+
+ ` + } + + function generateArtifactsHtml(artifacts, isFailedTest = false) { + if (!artifacts || artifacts.length === 0) { + output.print(`HTML Reporter: No artifacts found for test`) + return '' + } + + output.print(`HTML Reporter: Processing ${artifacts.length} artifacts, isFailedTest: ${isFailedTest}`) + output.print(`HTML Reporter: Artifacts: ${JSON.stringify(artifacts)}`) + + // Separate screenshots from other artifacts + const screenshots = [] + const otherArtifacts = [] + + artifacts.forEach(artifact => { + output.print(`HTML Reporter: Processing artifact: ${artifact} (type: ${typeof artifact})`) + + // Handle different artifact formats + let artifactPath = artifact + if (typeof artifact === 'object' && artifact.path) { + artifactPath = artifact.path + } else if (typeof artifact === 'object' && artifact.file) { + artifactPath = artifact.file + } else if (typeof artifact === 'object' && artifact.src) { + artifactPath = artifact.src + } + + // Check if it's a screenshot file + if (typeof artifactPath === 'string' && artifactPath.match(/\.(png|jpg|jpeg|gif|webp|bmp|svg)$/i)) { + screenshots.push(artifactPath) + output.print(`HTML Reporter: Found screenshot: ${artifactPath}`) + } else { + otherArtifacts.push(artifact) + output.print(`HTML Reporter: Found other artifact: ${artifact}`) + } + }) + + output.print(`HTML Reporter: Found ${screenshots.length} screenshots and ${otherArtifacts.length} other artifacts`) + + let artifactsHtml = '' + + // For failed tests, prominently display screenshots + if (isFailedTest && screenshots.length > 0) { + const screenshotsHtml = screenshots + .map(screenshot => { + let relativePath = path.relative(reportDir, screenshot) + const filename = path.basename(screenshot) + + // If relative path goes up directories, try to find the file in common locations + if (relativePath.startsWith('..')) { + // Try to find screenshot relative to output directory + const outputRelativePath = path.relative(reportDir, path.resolve(screenshot)) + if (!outputRelativePath.startsWith('..')) { + relativePath = outputRelativePath + } else { + // Use just the filename if file is in same directory as report + const sameDir = path.join(reportDir, filename) + if (fs.existsSync(sameDir)) { + relativePath = filename + } else { + // Keep original path as fallback + relativePath = screenshot + } + } + } + + output.print(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`) + + return ` +
+
+ 📸 ${escapeHtml(filename)} +
+ Test failure screenshot +
+ ` + }) + .join('') + + artifactsHtml += ` +
+

Screenshots:

+
${screenshotsHtml}
+
+ ` + } else if (screenshots.length > 0) { + // For non-failed tests, display screenshots normally + const screenshotsHtml = screenshots + .map(screenshot => { + let relativePath = path.relative(reportDir, screenshot) + const filename = path.basename(screenshot) + + // If relative path goes up directories, try to find the file in common locations + if (relativePath.startsWith('..')) { + // Try to find screenshot relative to output directory + const outputRelativePath = path.relative(reportDir, path.resolve(screenshot)) + if (!outputRelativePath.startsWith('..')) { + relativePath = outputRelativePath + } else { + // Use just the filename if file is in same directory as report + const sameDir = path.join(reportDir, filename) + if (fs.existsSync(sameDir)) { + relativePath = filename + } else { + // Keep original path as fallback + relativePath = screenshot + } + } + } + + output.print(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`) + return `Screenshot` + }) + .join('') + + artifactsHtml += ` +
+

Screenshots:

+
${screenshotsHtml}
+
+ ` + } + + // Display other artifacts if any + if (otherArtifacts.length > 0) { + const otherArtifactsHtml = otherArtifacts.map(artifact => `
${escapeHtml(artifact.toString())}
`).join('') + + artifactsHtml += ` +
+

Other Artifacts:

+
${otherArtifactsHtml}
+
+ ` + } + + return artifactsHtml + ? ` +
+ ${artifactsHtml} +
+ ` + : '' + } + + function generateFailuresHtml(failures) { + if (!failures || failures.length === 0) { + return '

No failures.

' + } + + return failures + .map((failure, index) => { + // Helper function to safely extract string values + const safeString = value => { + if (!value) return '' + if (typeof value === 'string') return value + if (typeof value === 'object' && value.toString) { + const str = value.toString() + return str === '[object Object]' ? '' : str + } + return String(value) + } + + if (typeof failure === 'object' && failure !== null) { + // Enhanced failure object with test details + console.log('this is failure', failure) + const testName = safeString(failure.testName) || 'Unknown Test' + const featureName = safeString(failure.featureName) || 'Unknown Feature' + let message = safeString(failure.message) || 'Test failed' + const stack = safeString(failure.stack) || '' + const filePath = safeString(failure.filePath) || '' + + // If message is still "[object Object]", try to extract from the failure object itself + if (message === '[object Object]' || message === '') { + if (failure.err && failure.err.message) { + message = safeString(failure.err.message) + } else if (failure.error && failure.error.message) { + message = safeString(failure.error.message) + } else if (failure.toString && typeof failure.toString === 'function') { + const str = failure.toString() + message = str === '[object Object]' ? 'Test failed' : str + } else { + message = 'Test failed' + } + } + + return ` +
+

Failure ${index + 1}: ${escapeHtml(testName)}

+
+ Feature: ${escapeHtml(featureName)} + ${filePath ? `File: ${escapeHtml(filePath)}` : ''} +
+
+ Error: ${escapeHtml(message)} +
+ ${stack ? `
${escapeHtml(stack.replace(/\x1b\[[0-9;]*m/g, ''))}
` : ''} +
+ ` + } else { + // Fallback for simple string failures + const failureText = safeString(failure).replace(/\x1b\[[0-9;]*m/g, '') || 'Test failed' + return ` +
+

Failure ${index + 1}

+
${escapeHtml(failureText)}
+
+ ` + } + }) + .join('') + } + + function generateRetriesHtml(retries) { + if (!retries || retries.length === 0) { + return '

No retried tests.

' + } + + return retries + .map( + retry => ` +
+

${retry.testTitle}

+
+ Attempts: ${retry.attempts} + Final State: ${retry.finalState} + Duration: ${formatDuration(retry.duration)} +
+
+ `, + ) + .join('') + } + + function formatDuration(duration) { + if (!duration) return '0ms' + if (duration < 1000) return `${duration}ms` + return `${(duration / 1000).toFixed(2)}s` + } + + function escapeHtml(text) { + if (!text) return '' + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + } + + function getErrorMessage(test) { + if (!test) return 'Test failed' + + // Helper function to safely extract string from potentially circular objects + const safeExtract = (obj, prop) => { + try { + if (!obj || typeof obj !== 'object') return '' + const value = obj[prop] + if (typeof value === 'string') return value + if (value && typeof value.toString === 'function') { + const str = value.toString() + return str === '[object Object]' ? '' : str + } + return '' + } catch (e) { + return '' + } + } + + // Helper function to safely stringify objects avoiding circular references + const safeStringify = obj => { + try { + if (!obj) return '' + if (typeof obj === 'string') return obj + + // Try to get message property first + if (obj.message && typeof obj.message === 'string') { + return obj.message + } + + // For error objects, extract key properties manually + if (obj instanceof Error || (obj.name && obj.message)) { + return obj.message || obj.toString() || 'Error occurred' + } + + // For other objects, try toString first + if (obj.toString && typeof obj.toString === 'function') { + const str = obj.toString() + if (str !== '[object Object]' && !str.includes('[Circular Reference]')) { + return str + } + } + + // Last resort: extract message-like properties + if (obj.message) return obj.message + if (obj.description) return obj.description + if (obj.text) return obj.text + + return 'Error occurred' + } catch (e) { + return 'Error occurred' + } + } + + let errorMessage = '' + let errorStack = '' + + // Primary error source + if (test.err) { + errorMessage = safeExtract(test.err, 'message') || safeStringify(test.err) + errorStack = safeExtract(test.err, 'stack') + } + + // Alternative error sources for different test frameworks + if (!errorMessage && test.error) { + errorMessage = safeExtract(test.error, 'message') || safeStringify(test.error) + errorStack = safeExtract(test.error, 'stack') + } + + // Check for nested error in parent + if (!errorMessage && test.parent && test.parent.err) { + errorMessage = safeExtract(test.parent.err, 'message') || safeStringify(test.parent.err) + errorStack = safeExtract(test.parent.err, 'stack') + } + + // Check for error details array (some frameworks use this) + if (!errorMessage && test.err && test.err.details && Array.isArray(test.err.details)) { + errorMessage = test.err.details + .map(item => safeExtract(item, 'message') || safeStringify(item)) + .filter(msg => msg && msg !== '[Circular]') + .join(' ') + } + + // Fallback to test title if no error message found + if (!errorMessage || errorMessage === '[Circular]') { + errorMessage = `Test failed: ${test.title || 'Unknown test'}` + } + + // Clean ANSI escape codes and remove circular reference markers + const cleanMessage = (errorMessage || '') + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/\[Circular\]/g, '') + .replace(/\s+/g, ' ') + .trim() + + const cleanStack = (errorStack || '') + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/\[Circular\]/g, '') + .trim() + + // Return combined error information + if (cleanStack && cleanStack !== cleanMessage && !cleanMessage.includes(cleanStack)) { + return `${cleanMessage}\n\nStack trace:\n${cleanStack}` + } + + return cleanMessage + } + + function generateSystemInfoHtml(systemInfo) { + if (!systemInfo) return '' + + const formatInfo = (key, value) => { + if (Array.isArray(value) && value.length > 1) { + return `
${key}: ${escapeHtml(value[1])}
` + } else if (typeof value === 'string' && value !== 'N/A' && value !== 'undefined') { + return `
${key}: ${escapeHtml(value)}
` + } + return '' + } + + const infoItems = [ + formatInfo('Node.js', systemInfo.nodeInfo), + formatInfo('OS', systemInfo.osInfo), + formatInfo('CPU', systemInfo.cpuInfo), + formatInfo('Chrome', systemInfo.chromeInfo), + formatInfo('Edge', systemInfo.edgeInfo), + formatInfo('Firefox', systemInfo.firefoxInfo), + formatInfo('Safari', systemInfo.safariInfo), + formatInfo('Playwright Browsers', systemInfo.playwrightBrowsers), + ] + .filter(item => item) + .join('') + + if (!infoItems) return '' + + return ` +
+
+

Environment Information

+ +
+
+
+ ${infoItems} +
+
+
+ ` + } + + function getHtmlTemplate() { + return ` + + + + + + {{title}} + + + +
+

{{title}}

+
+ Generated: {{timestamp}} + Duration: {{duration}} +
+
+ +
+ {{systemInfoHtml}} + +
+

Test Statistics

+ {{statsHtml}} +
+ +
+

Test History

+
+ +
+
+ +
+

Filters

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Test Results

+
+ {{testsHtml}} +
+
+ +
+

Test Retries

+
+ {{retriesHtml}} +
+
+ +
+ + + + + + + + + ` + } + + function getCssStyles() { + return ` +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: #333; + background-color: #f5f5f5; +} + +.report-header { + background: #2c3e50; + color: white; + padding: 2rem 1rem; + text-align: center; +} + +.report-header h1 { + margin-bottom: 0.5rem; + font-size: 2.5rem; +} + +.report-meta { + font-size: 0.9rem; + opacity: 0.8; +} + +.report-meta span { + margin: 0 1rem; +} + +.report-content { + max-width: 1200px; + margin: 2rem auto; + padding: 0 1rem; +} + +.stats-section, .tests-section, .retries-section, .filters-section, .history-section, .system-info-section { + background: white; + margin-bottom: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + overflow: hidden; +} + +.stats-section h2, .tests-section h2, .retries-section h2, .filters-section h2, .history-section h2 { + background: #34495e; + color: white; + padding: 1rem; + margin: 0; +} + +.stats-cards { + display: flex; + flex-wrap: wrap; + gap: 1rem; + padding: 1rem; +} + +.stat-card { + flex: 1; + min-width: 150px; + padding: 1rem; + text-align: center; + border-radius: 4px; + color: white; +} + +.stat-card.total { background: #3498db; } +.stat-card.passed { background: #27ae60; } +.stat-card.failed { background: #e74c3c; } +.stat-card.pending { background: #f39c12; } + +.stat-card h3 { + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.stat-number { + font-size: 2rem; + font-weight: bold; +} + +.pie-chart-container { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem 1rem; + background: white; + margin: 1rem 0; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +#statsChart { + max-width: 100%; + height: auto; +} + +.test-item { + border-bottom: 1px solid #eee; + margin: 0; +} + +.test-item:last-child { + border-bottom: none; +} + +.test-header { + display: flex; + align-items: center; + padding: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +.test-header:hover { + background-color: #f8f9fa; +} + +.test-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.test-meta-line { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; +} + +.test-status { + font-size: 1.2rem; + margin-right: 0.5rem; +} + +.test-status.passed { color: #27ae60; } +.test-status.failed { color: #e74c3c; } +.test-status.pending { color: #f39c12; } +.test-status.skipped { color: #95a5a6; } + +.test-title { + font-size: 1.1rem; + font-weight: 500; + margin: 0; +} + +.test-feature { + background: #ecf0f1; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + color: #34495e; +} + +.test-uid { + background: #e8f4fd; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + color: #2980b9; + font-family: monospace; +} + +.retry-badge { + background: #f39c12; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: bold; +} + +.test-duration { + font-size: 0.8rem; + color: #7f8c8d; +} + +.test-details { + display: none; + padding: 1rem; + background: #f8f9fa; + border-top: 1px solid #e9ecef; +} + +.error-message { + background: #fee; + border: 1px solid #fcc; + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; +} + +.error-message pre { + color: #c0392b; + font-family: 'Courier New', monospace; + font-size: 0.9rem; + white-space: pre-wrap; + word-wrap: break-word; +} + +.steps-section, .artifacts-section, .hooks-section { + margin-top: 1rem; +} + +.steps-section h4, .artifacts-section h4, .hooks-section h4 { + color: #34495e; + margin-bottom: 0.5rem; + font-size: 1rem; +} + +.hook-item { + display: flex; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid #ecf0f1; +} + +.hook-item:last-child { + border-bottom: none; +} + +.hook-status { + margin-right: 0.5rem; +} + +.hook-status.passed { color: #27ae60; } +.hook-status.failed { color: #e74c3c; } + +.hook-title { + flex: 1; + font-family: 'Courier New', monospace; + font-size: 0.9rem; + font-weight: bold; +} + +.hook-duration { + font-size: 0.8rem; + color: #7f8c8d; +} + +.hook-error { + width: 100%; + margin-top: 0.5rem; + padding: 0.5rem; + background: #fee; + border: 1px solid #fcc; + border-radius: 4px; + color: #c0392b; + font-size: 0.8rem; +} + +.step-item { + display: flex; + align-items: flex-start; + padding: 0.5rem 0; + border-bottom: 1px solid #ecf0f1; + word-wrap: break-word; + overflow-wrap: break-word; + min-height: 2rem; +} + +.step-item:last-child { + border-bottom: none; +} + +.step-status { + margin-right: 0.5rem; + flex-shrink: 0; + margin-top: 0.2rem; +} + +.step-status.success { color: #27ae60; } +.step-status.failed { color: #e74c3c; } + +.step-title { + flex: 1; + font-family: 'Courier New', monospace; + font-size: 0.9rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.4; + margin-right: 0.5rem; + min-width: 0; +} + +.step-duration { + font-size: 0.8rem; + color: #7f8c8d; + flex-shrink: 0; + margin-top: 0.2rem; +} + +.artifacts-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.artifact-image { + max-width: 200px; + max-height: 150px; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + transition: transform 0.2s; +} + +.artifact-image:hover { + transform: scale(1.05); +} + +.artifact-item { + background: #ecf0f1; + padding: 0.5rem; + border-radius: 4px; + font-size: 0.9rem; +} + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.8); + cursor: pointer; +} + +.modal img { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 90%; + max-height: 90%; + border-radius: 4px; +} + +/* Enhanced screenshot styles for failed tests */ +.screenshots-section { + margin-top: 1rem; +} + +.screenshots-section h4 { + color: #e74c3c; + margin-bottom: 0.75rem; + font-size: 1rem; + font-weight: 600; +} + +.screenshots-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.screenshot-container { + border: 2px solid #e74c3c; + border-radius: 8px; + overflow: hidden; + background: white; + box-shadow: 0 4px 8px rgba(231, 76, 60, 0.1); +} + +.screenshot-header { + background: #e74c3c; + color: white; + padding: 0.5rem 1rem; + font-size: 0.9rem; + font-weight: 500; +} + +.screenshot-label { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.failure-screenshot { + width: 100%; + max-width: 100%; + height: auto; + display: block; + cursor: pointer; + transition: opacity 0.2s; +} + +.failure-screenshot:hover { + opacity: 0.9; +} + +.other-artifacts-section { + margin-top: 1rem; +} + +/* Filter Controls */ +.filter-controls { + display: flex; + flex-wrap: wrap; + gap: 1rem; + padding: 1rem; + background: #f8f9fa; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.filter-group label { + font-size: 0.9rem; + font-weight: 500; + color: #34495e; +} + +.filter-group input, +.filter-group select { + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.9rem; + min-width: 150px; +} + +.filter-group select[multiple] { + height: auto; + min-height: 80px; +} + +.filter-controls button { + padding: 0.5rem 1rem; + background: #3498db; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + align-self: flex-end; +} + +.filter-controls button:hover { + background: #2980b9; +} + +/* Test Tags */ +.tags-section, .metadata-section, .notes-section, .retry-section { + margin-top: 1rem; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.test-tag { + background: #3498db; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.8rem; +} + +/* Metadata */ +.metadata-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.meta-item { + padding: 0.5rem; + background: #f8f9fa; + border-radius: 4px; + border-left: 3px solid #3498db; +} + +.meta-key { + font-weight: bold; + color: #2c3e50; +} + +.meta-value { + color: #34495e; + font-family: monospace; +} + +/* Notes */ +.notes-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.note-item { + padding: 0.5rem; + border-radius: 4px; + border-left: 3px solid #95a5a6; +} + +.note-item.note-info { + background: #e8f4fd; + border-left-color: #3498db; +} + +.note-item.note-warning { + background: #fef9e7; + border-left-color: #f39c12; +} + +.note-item.note-error { + background: #fee; + border-left-color: #e74c3c; +} + +.note-item.note-retry { + background: #f0f8e8; + border-left-color: #27ae60; +} + +.note-type { + font-weight: bold; + text-transform: uppercase; + font-size: 0.8rem; +} + +/* Retry Information */ +.retry-info { + padding: 0.5rem; + background: #fef9e7; + border-radius: 4px; + border-left: 3px solid #f39c12; +} + +.retry-count { + color: #d68910; + font-weight: 500; +} + +/* Retries Section */ +.retry-item { + padding: 1rem; + margin-bottom: 1rem; + border: 1px solid #f39c12; + border-radius: 4px; + background: #fef9e7; +} + +.retry-item h4 { + color: #d68910; + margin-bottom: 0.5rem; +} + +.retry-details { + display: flex; + gap: 1rem; + align-items: center; + font-size: 0.9rem; +} + +.status-badge { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: bold; + text-transform: uppercase; +} + +.status-badge.passed { + background: #27ae60; + color: white; +} + +.status-badge.failed { + background: #e74c3c; + color: white; +} + +.status-badge.pending { + background: #f39c12; + color: white; +} + +/* History Chart */ +.history-chart-container { + padding: 2rem 1rem; + display: flex; + justify-content: center; +} + +#historyChart { + max-width: 100%; + height: auto; +} + +/* Hidden items for filtering */ +.test-item.filtered-out { + display: none !important; +} + +/* System Info Section */ +.system-info-section { + background: white; + margin-bottom: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + overflow: hidden; +} + +.system-info-header { + background: #2c3e50; + color: white; + padding: 1rem; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background-color 0.2s; +} + +.system-info-header:hover { + background: #34495e; +} + +.system-info-header h3 { + margin: 0; + font-size: 1.2rem; +} + +.toggle-icon { + font-size: 1rem; + transition: transform 0.3s ease; +} + +.toggle-icon.rotated { + transform: rotate(-180deg); +} + +.system-info-content { + display: none; + padding: 1.5rem; + background: #f8f9fa; + border-top: 1px solid #e9ecef; +} + +.system-info-content.visible { + display: block; +} + +.system-info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; +} + +.info-item { + padding: 0.75rem; + background: white; + border-radius: 6px; + border-left: 4px solid #3498db; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.info-key { + font-weight: bold; + color: #2c3e50; + display: inline-block; + min-width: 100px; +} + +.info-value { + color: #34495e; + font-family: 'Courier New', monospace; + font-size: 0.9rem; +} + +/* BDD/Gherkin specific styles */ +.bdd-test { + border-left: 4px solid #8e44ad; +} + +.bdd-badge { + background: #8e44ad; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: bold; +} + +.bdd-feature-section { + margin-top: 1rem; + padding: 1rem; + background: #f8f9fa; + border-left: 4px solid #8e44ad; + border-radius: 4px; +} + +.feature-name { + font-weight: bold; + font-size: 1.1rem; + color: #8e44ad; + margin-bottom: 0.5rem; +} + +.feature-description { + color: #34495e; + font-style: italic; + margin: 0.5rem 0; + padding: 0.5rem; + background: white; + border-radius: 4px; +} + +.feature-file { + font-size: 0.8rem; + color: #7f8c8d; + margin-top: 0.5rem; +} + +.feature-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0.5rem 0; +} + +.feature-tag { + background: #8e44ad; + color: white; + padding: 0.2rem 0.4rem; + border-radius: 8px; + font-size: 0.7rem; +} + +.bdd-steps-section { + margin-top: 1rem; +} + +.bdd-steps-section h4 { + color: #8e44ad; + margin-bottom: 0.5rem; + font-size: 1rem; +} + +.bdd-step-item { + display: flex; + align-items: flex-start; + padding: 0.5rem 0; + border-bottom: 1px solid #ecf0f1; + font-family: 'Segoe UI', sans-serif; + word-wrap: break-word; + overflow-wrap: break-word; + min-height: 2rem; +} + +.bdd-step-item:last-child { + border-bottom: none; +} + +.bdd-keyword { + font-weight: bold; + color: #8e44ad; + margin-right: 0.5rem; + min-width: 60px; + text-align: left; + flex-shrink: 0; +} + +.bdd-step-text { + flex: 1; + color: #2c3e50; + margin-right: 0.5rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.4; + min-width: 0; +} + +.step-comment { + width: 100%; + margin-top: 0.5rem; + padding: 0.5rem; + background: #f8f9fa; + border-left: 3px solid #8e44ad; + font-style: italic; + color: #6c757d; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.4; +} + +@media (max-width: 768px) { + .stats-cards { + flex-direction: column; + } + + .test-header { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + .test-feature, .test-duration { + align-self: flex-start; + } +} + ` + } + + function getJsScripts() { + return ` +function toggleTestDetails(testId) { + const details = document.getElementById('details-' + testId); + if (details.style.display === 'none' || details.style.display === '') { + details.style.display = 'block'; + } else { + details.style.display = 'none'; + } +} + +function openImageModal(src) { + const modal = document.getElementById('imageModal'); + const modalImg = document.getElementById('modalImage'); + modalImg.src = src; + modal.style.display = 'block'; +} + +function closeImageModal() { + const modal = document.getElementById('imageModal'); + modal.style.display = 'none'; +} + +function toggleSystemInfo() { + const content = document.getElementById('systemInfoContent'); + const icon = document.querySelector('.toggle-icon'); + + if (content.classList.contains('visible')) { + content.classList.remove('visible'); + icon.classList.remove('rotated'); + } else { + content.classList.add('visible'); + icon.classList.add('rotated'); + } +} + +// Filter functionality +function applyFilters() { + const statusFilter = Array.from(document.getElementById('statusFilter').selectedOptions).map(opt => opt.value); + const featureFilter = document.getElementById('featureFilter').value.toLowerCase(); + const tagFilter = document.getElementById('tagFilter').value.toLowerCase(); + const retryFilter = document.getElementById('retryFilter').value; + const typeFilter = document.getElementById('typeFilter').value; + + const testItems = document.querySelectorAll('.test-item'); + + testItems.forEach(item => { + let shouldShow = true; + + // Status filter + if (statusFilter.length > 0) { + const testStatus = item.dataset.status; + if (!statusFilter.includes(testStatus)) { + shouldShow = false; + } + } + + // Feature filter + if (featureFilter && shouldShow) { + const feature = (item.dataset.feature || '').toLowerCase(); + if (!feature.includes(featureFilter)) { + shouldShow = false; + } + } + + // Tag filter + if (tagFilter && shouldShow) { + const tags = (item.dataset.tags || '').toLowerCase(); + if (!tags.includes(tagFilter)) { + shouldShow = false; + } + } + + // Retry filter + if (retryFilter !== 'all' && shouldShow) { + const retries = parseInt(item.dataset.retries || '0'); + if (retryFilter === 'retried' && retries === 0) { + shouldShow = false; + } else if (retryFilter === 'no-retries' && retries > 0) { + shouldShow = false; + } + } + + // Test type filter (BDD/Gherkin vs Regular) + if (typeFilter !== 'all' && shouldShow) { + const testType = item.dataset.type || 'regular'; + if (typeFilter !== testType) { + shouldShow = false; + } + } + + if (shouldShow) { + item.classList.remove('filtered-out'); + } else { + item.classList.add('filtered-out'); + } + }); + + updateFilteredStats(); +} + +function resetFilters() { + document.getElementById('statusFilter').selectedIndex = -1; + document.getElementById('featureFilter').value = ''; + document.getElementById('tagFilter').value = ''; + document.getElementById('retryFilter').value = 'all'; + document.getElementById('typeFilter').value = 'all'; + + document.querySelectorAll('.test-item').forEach(item => { + item.classList.remove('filtered-out'); + }); + + updateFilteredStats(); +} + +function updateFilteredStats() { + const visibleTests = document.querySelectorAll('.test-item:not(.filtered-out)'); + const totalVisible = visibleTests.length; + + // Update the title to show filtered count + const testsSection = document.querySelector('.tests-section h2'); + const totalTests = document.querySelectorAll('.test-item').length; + + if (totalVisible !== totalTests) { + testsSection.textContent = 'Test Results (' + totalVisible + ' of ' + totalTests + ' shown)'; + } else { + testsSection.textContent = 'Test Results'; + } +} + +// Draw pie chart using canvas +function drawPieChart() { + const canvas = document.getElementById('statsChart'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const data = window.chartData; + + if (!data) return; + + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const radius = Math.min(centerX, centerY) - 20; + + const total = data.passed + data.failed + data.pending; + if (total === 0) { + // Draw empty circle for no tests + ctx.strokeStyle = '#ddd'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); + ctx.stroke(); + ctx.fillStyle = '#888'; + ctx.font = '16px Arial'; + ctx.textAlign = 'center'; + ctx.fillText('No Tests', centerX, centerY); + return; + } + + let currentAngle = -Math.PI / 2; // Start from top + + // Calculate percentages + const passedPercent = Math.round((data.passed / total) * 100); + const failedPercent = Math.round((data.failed / total) * 100); + const pendingPercent = Math.round((data.pending / total) * 100); + + // Draw passed segment + if (data.passed > 0) { + const angle = (data.passed / total) * 2 * Math.PI; + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle); + ctx.closePath(); + ctx.fillStyle = '#27ae60'; + ctx.fill(); + + // Add percentage text on segment if significant enough + if (passedPercent >= 10) { + const textAngle = currentAngle + angle / 2; + const textRadius = radius * 0.7; + const textX = centerX + Math.cos(textAngle) * textRadius; + const textY = centerY + Math.sin(textAngle) * textRadius; + + ctx.fillStyle = '#fff'; + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(passedPercent + '%', textX, textY); + } + + currentAngle += angle; + } + + // Draw failed segment + if (data.failed > 0) { + const angle = (data.failed / total) * 2 * Math.PI; + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle); + ctx.closePath(); + ctx.fillStyle = '#e74c3c'; + ctx.fill(); + + // Add percentage text on segment if significant enough + if (failedPercent >= 10) { + const textAngle = currentAngle + angle / 2; + const textRadius = radius * 0.7; + const textX = centerX + Math.cos(textAngle) * textRadius; + const textY = centerY + Math.sin(textAngle) * textRadius; + + ctx.fillStyle = '#fff'; + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(failedPercent + '%', textX, textY); + } + + currentAngle += angle; + } + + // Draw pending segment + if (data.pending > 0) { + const angle = (data.pending / total) * 2 * Math.PI; + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle); + ctx.closePath(); + ctx.fillStyle = '#f39c12'; + ctx.fill(); + + // Add percentage text on segment if significant enough + if (pendingPercent >= 10) { + const textAngle = currentAngle + angle / 2; + const textRadius = radius * 0.7; + const textX = centerX + Math.cos(textAngle) * textRadius; + const textY = centerY + Math.sin(textAngle) * textRadius; + + ctx.fillStyle = '#fff'; + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(pendingPercent + '%', textX, textY); + } + } + + // Add legend with percentages + const legendY = centerY + radius + 40; + ctx.font = '14px Arial'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + + let legendX = centerX - 150; + + // Passed legend + ctx.fillStyle = '#27ae60'; + ctx.fillRect(legendX, legendY, 15, 15); + ctx.fillStyle = '#333'; + ctx.fillText('Passed (' + data.passed + ' - ' + passedPercent + '%)', legendX + 20, legendY + 12); + + // Failed legend + legendX += 130; + ctx.fillStyle = '#e74c3c'; + ctx.fillRect(legendX, legendY, 15, 15); + ctx.fillStyle = '#333'; + ctx.fillText('Failed (' + data.failed + ' - ' + failedPercent + '%)', legendX + 20, legendY + 12); + + // Pending legend + if (data.pending > 0) { + legendX += 120; + ctx.fillStyle = '#f39c12'; + ctx.fillRect(legendX, legendY, 15, 15); + ctx.fillStyle = '#333'; + ctx.fillText('Pending (' + data.pending + ' - ' + pendingPercent + '%)', legendX + 20, legendY + 12); + } +} + +// Draw history chart +function drawHistoryChart() { + const canvas = document.getElementById('historyChart'); + + if (!canvas || !window.testData || !window.testData.history || window.testData.history.length === 0) { + return; + } + + const ctx = canvas.getContext('2d'); + const history = window.testData.history.slice().reverse(); // Most recent last + console.log('History chart - Total data points:', window.testData.history.length); + console.log('History chart - Processing points:', history.length); + console.log('History chart - Raw history data:', window.testData.history); + console.log('History chart - Reversed history:', history); + + const padding = 60; + const bottomPadding = 80; // Extra space for timestamps + const chartWidth = canvas.width - 2 * padding; + const chartHeight = canvas.height - padding - bottomPadding; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Calculate success rates and max values + const dataPoints = history.map((run, index) => { + const total = run.stats.tests || 0; + const passed = run.stats.passes || 0; + const failed = run.stats.failures || 0; + const successRate = total > 0 ? (passed / total) * 100 : 0; + const timestamp = new Date(run.timestamp); + + return { + index, + timestamp, + total, + passed, + failed, + successRate, + duration: run.duration || 0, + retries: run.retries || 0 + }; + }); + + console.log('History chart - Data points created:', dataPoints.length); + console.log('History chart - Data points:', dataPoints); + + const maxTests = Math.max(...dataPoints.map(d => d.total)); + const maxSuccessRate = 100; + + if (maxTests === 0) return; + + // Draw background + ctx.fillStyle = '#fafafa'; + ctx.fillRect(padding, padding, chartWidth, chartHeight); + + // Draw axes + ctx.strokeStyle = '#333'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padding, padding); + ctx.lineTo(padding, padding + chartHeight); + ctx.lineTo(padding + chartWidth, padding + chartHeight); + ctx.stroke(); + + // Draw grid lines + ctx.strokeStyle = '#e0e0e0'; + ctx.lineWidth = 1; + for (let i = 1; i <= 4; i++) { + const y = padding + (chartHeight * i / 4); + ctx.beginPath(); + ctx.moveTo(padding, y); + ctx.lineTo(padding + chartWidth, y); + ctx.stroke(); + } + + // Calculate positions + const stepX = dataPoints.length > 1 ? chartWidth / (dataPoints.length - 1) : chartWidth / 2; + + // Draw success rate area chart + ctx.fillStyle = 'rgba(39, 174, 96, 0.1)'; + ctx.strokeStyle = '#27ae60'; + ctx.lineWidth = 3; + ctx.beginPath(); + + dataPoints.forEach((point, index) => { + const x = dataPoints.length === 1 ? padding + chartWidth / 2 : padding + (index * stepX); + const y = padding + chartHeight - (point.successRate / maxSuccessRate) * chartHeight; + + if (index === 0) { + ctx.moveTo(x, padding + chartHeight); + ctx.lineTo(x, y); + } else { + ctx.lineTo(x, y); + } + + point.x = x; + point.y = y; + }); + + // Close the area + if (dataPoints.length > 0) { + const lastPoint = dataPoints[dataPoints.length - 1]; + ctx.lineTo(lastPoint.x, padding + chartHeight); + ctx.closePath(); + ctx.fill(); + } + + // Draw success rate line + ctx.strokeStyle = '#27ae60'; + ctx.lineWidth = 3; + ctx.beginPath(); + dataPoints.forEach((point, index) => { + if (index === 0) { + ctx.moveTo(point.x, point.y); + } else { + ctx.lineTo(point.x, point.y); + } + }); + ctx.stroke(); + + // Draw data points with enhanced styling + dataPoints.forEach(point => { + // Outer ring based on status + const ringColor = point.failed > 0 ? '#e74c3c' : '#27ae60'; + ctx.strokeStyle = ringColor; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(point.x, point.y, 8, 0, 2 * Math.PI); + ctx.stroke(); + + // Inner circle + ctx.fillStyle = point.failed > 0 ? '#e74c3c' : '#27ae60'; + ctx.beginPath(); + ctx.arc(point.x, point.y, 5, 0, 2 * Math.PI); + ctx.fill(); + + // White center dot + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(point.x, point.y, 2, 0, 2 * Math.PI); + ctx.fill(); + }); + + // Y-axis labels (Success Rate %) + ctx.fillStyle = '#666'; + ctx.font = '11px Arial'; + ctx.textAlign = 'right'; + for (let i = 0; i <= 4; i++) { + const value = Math.round((maxSuccessRate * i) / 4); + const y = padding + chartHeight - (chartHeight * i / 4); + ctx.fillText(value + '%', padding - 10, y + 4); + } + + // X-axis labels (Timestamps) + ctx.textAlign = 'center'; + ctx.font = '10px Arial'; + dataPoints.forEach((point, index) => { + const timeStr = point.timestamp.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + const dateStr = point.timestamp.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); + + console.log('Drawing label ' + index + ': ' + timeStr + ' at x=' + point.x); + ctx.fillText(timeStr, point.x, padding + chartHeight + 15); + ctx.fillText(dateStr, point.x, padding + chartHeight + 30); + }); + + // Enhanced legend with statistics + const legendY = 25; + ctx.font = '12px Arial'; + ctx.textAlign = 'left'; + + // Success rate legend + ctx.fillStyle = '#27ae60'; + ctx.fillRect(padding + 20, legendY, 15, 15); + ctx.fillStyle = '#333'; + ctx.fillText('Success Rate', padding + 40, legendY + 12); + + // Current stats + if (dataPoints.length > 0) { + const latest = dataPoints[dataPoints.length - 1]; + const trend = dataPoints.length > 1 ? + (latest.successRate - dataPoints[dataPoints.length - 2].successRate) : 0; + const trendIcon = trend > 0 ? '↗' : trend < 0 ? '↘' : '→'; + const trendColor = trend > 0 ? '#27ae60' : trend < 0 ? '#e74c3c' : '#666'; + + ctx.fillStyle = '#666'; + ctx.fillText('Latest: ' + latest.successRate.toFixed(1) + '%', padding + 150, legendY + 12); + + ctx.fillStyle = trendColor; + ctx.fillText(trendIcon + ' ' + Math.abs(trend).toFixed(1) + '%', padding + 240, legendY + 12); + } + + // Chart title + ctx.fillStyle = '#333'; + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.fillText('Test Success Rate History', canvas.width / 2, 20); +} + +// Initialize charts and filters +document.addEventListener('DOMContentLoaded', function() { + + // Draw charts + drawPieChart(); + drawHistoryChart(); + + // Set up filter event listeners + document.getElementById('statusFilter').addEventListener('change', applyFilters); + document.getElementById('featureFilter').addEventListener('input', applyFilters); + document.getElementById('tagFilter').addEventListener('input', applyFilters); + document.getElementById('retryFilter').addEventListener('change', applyFilters); + document.getElementById('typeFilter').addEventListener('change', applyFilters); +}); + ` + } +} diff --git a/lib/test-server.js b/lib/test-server.js new file mode 100644 index 000000000..25d4d51db --- /dev/null +++ b/lib/test-server.js @@ -0,0 +1,323 @@ +const express = require('express') +const fs = require('fs') +const path = require('path') + +/** + * Internal API test server to replace json-server dependency + * Provides REST API endpoints for testing CodeceptJS helpers + */ +class TestServer { + constructor(config = {}) { + this.app = express() + this.server = null + this.port = config.port || 8010 + this.host = config.host || 'localhost' + this.dbFile = config.dbFile || path.join(__dirname, '../test/data/rest/db.json') + this.lastModified = null + this.data = this.loadData() + + this.setupMiddleware() + this.setupRoutes() + this.setupFileWatcher() + } + + loadData() { + try { + const content = fs.readFileSync(this.dbFile, 'utf8') + const data = JSON.parse(content) + // Update lastModified time when loading data + if (fs.existsSync(this.dbFile)) { + this.lastModified = fs.statSync(this.dbFile).mtime + } + console.log('[Data Load] Loaded data from file:', JSON.stringify(data)) + return data + } catch (err) { + console.warn(`[Data Load] Could not load data file ${this.dbFile}:`, err.message) + console.log('[Data Load] Using fallback default data') + return { + posts: [{ id: 1, title: 'json-server', author: 'davert' }], + user: { name: 'john', password: '123456' }, + } + } + } + + reloadData() { + console.log('[Reload] Reloading data from file...') + this.data = this.loadData() + console.log('[Reload] Data reloaded successfully') + return this.data + } + + saveData() { + try { + fs.writeFileSync(this.dbFile, JSON.stringify(this.data, null, 2)) + console.log('[Save] Data saved to file') + // Force update modification time to ensure auto-reload works + const now = new Date() + fs.utimesSync(this.dbFile, now, now) + this.lastModified = now + console.log('[Save] File modification time updated') + } catch (err) { + console.warn(`[Save] Could not save data file ${this.dbFile}:`, err.message) + } + } + + setupMiddleware() { + // Parse JSON bodies + this.app.use(express.json()) + + // Parse URL-encoded bodies + this.app.use(express.urlencoded({ extended: true })) + + // CORS support + this.app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Test') + + if (req.method === 'OPTIONS') { + res.status(200).end() + return + } + next() + }) + + // Auto-reload middleware - check if file changed before each request + this.app.use((req, res, next) => { + try { + if (fs.existsSync(this.dbFile)) { + const stats = fs.statSync(this.dbFile) + if (!this.lastModified || stats.mtime > this.lastModified) { + console.log(`[Auto-reload] Database file changed (${this.dbFile}), reloading data...`) + console.log(`[Auto-reload] Old mtime: ${this.lastModified}, New mtime: ${stats.mtime}`) + this.reloadData() + this.lastModified = stats.mtime + console.log(`[Auto-reload] Data reloaded, user name is now: ${this.data.user?.name}`) + } + } + } catch (err) { + console.warn('[Auto-reload] Error checking file modification time:', err.message) + } + next() + }) + + // Logging middleware + this.app.use((req, res, next) => { + console.log(`${req.method} ${req.path}`) + next() + }) + } + + setupRoutes() { + // Reload endpoint (for testing) + this.app.post('/_reload', (req, res) => { + this.reloadData() + res.json({ message: 'Data reloaded', data: this.data }) + }) + + // Headers endpoint (for header testing) + this.app.get('/headers', (req, res) => { + res.json(req.headers) + }) + + this.app.post('/headers', (req, res) => { + res.json(req.headers) + }) + + // User endpoints + this.app.get('/user', (req, res) => { + console.log(`[GET /user] Serving user data: ${JSON.stringify(this.data.user)}`) + res.json(this.data.user) + }) + + this.app.post('/user', (req, res) => { + this.data.user = { ...this.data.user, ...req.body } + this.saveData() + res.status(201).json(this.data.user) + }) + + this.app.patch('/user', (req, res) => { + this.data.user = { ...this.data.user, ...req.body } + this.saveData() + res.json(this.data.user) + }) + + this.app.put('/user', (req, res) => { + this.data.user = req.body + this.saveData() + res.json(this.data.user) + }) + + // Posts endpoints + this.app.get('/posts', (req, res) => { + res.json(this.data.posts) + }) + + this.app.get('/posts/:id', (req, res) => { + const id = parseInt(req.params.id) + const post = this.data.posts.find(p => p.id === id) + + if (!post) { + // Return empty object instead of 404 for json-server compatibility + return res.json({}) + } + + res.json(post) + }) + + this.app.post('/posts', (req, res) => { + const newId = Math.max(...this.data.posts.map(p => p.id || 0)) + 1 + const newPost = { id: newId, ...req.body } + + this.data.posts.push(newPost) + this.saveData() + res.status(201).json(newPost) + }) + + this.app.put('/posts/:id', (req, res) => { + const id = parseInt(req.params.id) + const postIndex = this.data.posts.findIndex(p => p.id === id) + + if (postIndex === -1) { + return res.status(404).json({ error: 'Post not found' }) + } + + this.data.posts[postIndex] = { id, ...req.body } + this.saveData() + res.json(this.data.posts[postIndex]) + }) + + this.app.patch('/posts/:id', (req, res) => { + const id = parseInt(req.params.id) + const postIndex = this.data.posts.findIndex(p => p.id === id) + + if (postIndex === -1) { + return res.status(404).json({ error: 'Post not found' }) + } + + this.data.posts[postIndex] = { ...this.data.posts[postIndex], ...req.body } + this.saveData() + res.json(this.data.posts[postIndex]) + }) + + this.app.delete('/posts/:id', (req, res) => { + const id = parseInt(req.params.id) + const postIndex = this.data.posts.findIndex(p => p.id === id) + + if (postIndex === -1) { + return res.status(404).json({ error: 'Post not found' }) + } + + const deletedPost = this.data.posts.splice(postIndex, 1)[0] + this.saveData() + res.json(deletedPost) + }) + + // File upload endpoint (basic implementation) + this.app.post('/upload', (req, res) => { + // Simple upload simulation - for more complex file uploads, + // multer would be needed but basic tests should work + res.json({ + message: 'File upload endpoint available', + headers: req.headers, + body: req.body, + }) + }) + + // Comments endpoints (for ApiDataFactory tests) + this.app.get('/comments', (req, res) => { + res.json(this.data.comments || []) + }) + + this.app.post('/comments', (req, res) => { + if (!this.data.comments) this.data.comments = [] + const newId = Math.max(...this.data.comments.map(c => c.id || 0), 0) + 1 + const newComment = { id: newId, ...req.body } + + this.data.comments.push(newComment) + this.saveData() + res.status(201).json(newComment) + }) + + this.app.delete('/comments/:id', (req, res) => { + if (!this.data.comments) this.data.comments = [] + const id = parseInt(req.params.id) + const commentIndex = this.data.comments.findIndex(c => c.id === id) + + if (commentIndex === -1) { + return res.status(404).json({ error: 'Comment not found' }) + } + + const deletedComment = this.data.comments.splice(commentIndex, 1)[0] + this.saveData() + res.json(deletedComment) + }) + + // Generic catch-all for other endpoints + this.app.use((req, res) => { + res.status(404).json({ error: 'Endpoint not found' }) + }) + } + + setupFileWatcher() { + if (fs.existsSync(this.dbFile)) { + fs.watchFile(this.dbFile, (current, previous) => { + if (current.mtime !== previous.mtime) { + console.log('Database file changed, reloading data...') + this.reloadData() + } + }) + } + } + + start() { + return new Promise((resolve, reject) => { + this.server = this.app.listen(this.port, this.host, err => { + if (err) { + reject(err) + } else { + console.log(`Test server running on http://${this.host}:${this.port}`) + resolve(this.server) + } + }) + }) + } + + stop() { + return new Promise(resolve => { + if (this.server) { + this.server.close(() => { + console.log('Test server stopped') + resolve() + }) + } else { + resolve() + } + }) + } +} + +module.exports = TestServer + +// CLI usage +if (require.main === module) { + const config = { + port: process.env.PORT || 8010, + host: process.env.HOST || '0.0.0.0', + dbFile: process.argv[2] || path.join(__dirname, '../test/data/rest/db.json'), + } + + const server = new TestServer(config) + server.start().catch(console.error) + + // Graceful shutdown + process.on('SIGINT', () => { + console.log('\nShutting down test server...') + server.stop().then(() => process.exit(0)) + }) + + process.on('SIGTERM', () => { + console.log('\nShutting down test server...') + server.stop().then(() => process.exit(0)) + }) +} diff --git a/lib/utils/mask_data.js b/lib/utils/mask_data.js new file mode 100644 index 000000000..1a1dc4b4c --- /dev/null +++ b/lib/utils/mask_data.js @@ -0,0 +1,53 @@ +const { maskSensitiveData } = require('invisi-data') + +/** + * Mask sensitive data utility for CodeceptJS + * Supports both boolean and object configuration formats + * + * @param {string} input - The string to mask + * @param {boolean|object} config - Masking configuration + * @returns {string} - Masked string + */ +function maskData(input, config) { + if (!config) { + return input + } + + // Handle boolean config (backward compatibility) + if (typeof config === 'boolean' && config === true) { + return maskSensitiveData(input) + } + + // Handle object config with custom patterns + if (typeof config === 'object' && config.enabled === true) { + const customPatterns = config.patterns || [] + return maskSensitiveData(input, customPatterns) + } + + return input +} + +/** + * Check if masking is enabled based on global configuration + * + * @returns {boolean|object} - Current masking configuration + */ +function getMaskConfig() { + return global.maskSensitiveData || false +} + +/** + * Check if data should be masked + * + * @returns {boolean} - True if masking is enabled + */ +function shouldMaskData() { + const config = getMaskConfig() + return config === true || (typeof config === 'object' && config.enabled === true) +} + +module.exports = { + maskData, + getMaskConfig, + shouldMaskData, +} diff --git a/lib/workers.js b/lib/workers.js index 1576263b3..3ee853023 100644 --- a/lib/workers.js +++ b/lib/workers.js @@ -49,13 +49,14 @@ const populateGroups = numberOfWorkers => { return groups } -const createWorker = workerObject => { +const createWorker = (workerObject, isPoolMode = false) => { const worker = new Worker(pathToWorker, { workerData: { options: simplifyObject(workerObject.options), tests: workerObject.tests, testRoot: workerObject.testRoot, workerIndex: workerObject.workerIndex + 1, + poolMode: isPoolMode, }, }) worker.on('error', err => output.error(`Worker Error: ${err.stack}`)) @@ -231,11 +232,17 @@ class Workers extends EventEmitter { super() this.setMaxListeners(50) this.codecept = initializeCodecept(config.testConfig, config.options) + this.options = config.options || {} this.errors = [] this.numberOfWorkers = 0 this.closedWorkers = 0 this.workers = [] this.testGroups = [] + this.testPool = [] + this.testPoolInitialized = false + this.isPoolMode = config.by === 'pool' + this.activeWorkers = new Map() + this.maxWorkers = numberOfWorkers // Track original worker count for pool mode createOutputDir(config.testConfig) if (numberOfWorkers) this._initWorkers(numberOfWorkers, config) @@ -255,6 +262,7 @@ class Workers extends EventEmitter { * * - `suite` * - `test` + * - `pool` * - function(numberOfWorkers) * * This method can be overridden for a better split. @@ -270,7 +278,11 @@ class Workers extends EventEmitter { this.testGroups.push(convertToMochaTests(testGroup)) } } else if (typeof numberOfWorkers === 'number' && numberOfWorkers > 0) { - this.testGroups = config.by === 'suite' ? this.createGroupsOfSuites(numberOfWorkers) : this.createGroupsOfTests(numberOfWorkers) + if (config.by === 'pool') { + this.createTestPool(numberOfWorkers) + } else { + this.testGroups = config.by === 'suite' ? this.createGroupsOfSuites(numberOfWorkers) : this.createGroupsOfTests(numberOfWorkers) + } } } @@ -308,6 +320,85 @@ class Workers extends EventEmitter { return groups } + /** + * @param {Number} numberOfWorkers + */ + createTestPool(numberOfWorkers) { + // For pool mode, create empty groups for each worker and initialize empty pool + // Test pool will be populated lazily when getNextTest() is first called + this.testPool = [] + this.testPoolInitialized = false + this.testGroups = populateGroups(numberOfWorkers) + } + + /** + * Initialize the test pool if not already done + * This is called lazily to avoid state pollution issues during construction + */ + _initializeTestPool() { + if (this.testPoolInitialized) { + return + } + + const files = this.codecept.testFiles + if (!files || files.length === 0) { + this.testPoolInitialized = true + return + } + + try { + const mocha = Container.mocha() + mocha.files = files + mocha.loadFiles() + + mocha.suite.eachTest(test => { + if (test) { + this.testPool.push(test.uid) + } + }) + } catch (e) { + // If mocha loading fails due to state pollution, skip + } + + // If no tests were found, fallback to using createGroupsOfTests approach + // This works around state pollution issues + if (this.testPool.length === 0 && files.length > 0) { + try { + const testGroups = this.createGroupsOfTests(2) // Use 2 as a default for fallback + for (const group of testGroups) { + this.testPool.push(...group) + } + } catch (e) { + // If createGroupsOfTests fails, fallback to simple file names + for (const file of files) { + this.testPool.push(`test_${file.replace(/[^a-zA-Z0-9]/g, '_')}`) + } + } + } + + // Last resort fallback for unit tests - add dummy test UIDs + if (this.testPool.length === 0) { + for (let i = 0; i < Math.min(files.length, 5); i++) { + this.testPool.push(`dummy_test_${i}_${Date.now()}`) + } + } + + this.testPoolInitialized = true + } + + /** + * Gets the next test from the pool + * @returns {String|null} test uid or null if no tests available + */ + getNextTest() { + // Initialize test pool lazily on first access + if (!this.testPoolInitialized) { + this._initializeTestPool() + } + + return this.testPool.shift() || null + } + /** * @param {Number} numberOfWorkers */ @@ -352,7 +443,7 @@ class Workers extends EventEmitter { process.env.RUNS_WITH_WORKERS = 'true' recorder.add('starting workers', () => { for (const worker of this.workers) { - const workerThread = createWorker(worker) + const workerThread = createWorker(worker, this.isPoolMode) this._listenWorkerEvents(workerThread) } }) @@ -376,9 +467,27 @@ class Workers extends EventEmitter { } _listenWorkerEvents(worker) { + // Track worker thread for pool mode + if (this.isPoolMode) { + this.activeWorkers.set(worker, { available: true, workerIndex: null }) + } + worker.on('message', message => { output.process(message.workerIndex) + // Handle test requests for pool mode + if (message.type === 'REQUEST_TEST') { + if (this.isPoolMode) { + const nextTest = this.getNextTest() + if (nextTest) { + worker.postMessage({ type: 'TEST_ASSIGNED', test: nextTest }) + } else { + worker.postMessage({ type: 'NO_MORE_TESTS' }) + } + } + return + } + // deal with events that are not test cycle related if (!message.event) { return this.emit('message', message) @@ -387,11 +496,21 @@ class Workers extends EventEmitter { switch (message.event) { case event.all.result: // we ensure consistency of result by adding tests in the very end - Container.result().addFailures(message.data.failures) - Container.result().addStats(message.data.stats) - message.data.tests.forEach(test => { - Container.result().addTest(deserializeTest(test)) - }) + // Check if message.data.stats is valid before adding + if (message.data.stats) { + Container.result().addStats(message.data.stats) + } + + if (message.data.failures) { + Container.result().addFailures(message.data.failures) + } + + if (message.data.tests) { + message.data.tests.forEach(test => { + Container.result().addTest(deserializeTest(test)) + }) + } + break case event.suite.before: this.emit(event.suite.before, deserializeSuite(message.data)) @@ -438,7 +557,14 @@ class Workers extends EventEmitter { worker.on('exit', () => { this.closedWorkers += 1 - if (this.closedWorkers === this.numberOfWorkers) { + + if (this.isPoolMode) { + // Pool mode: finish when all workers have exited and no more tests + if (this.closedWorkers === this.numberOfWorkers) { + this._finishRun() + } + } else if (this.closedWorkers === this.numberOfWorkers) { + // Regular mode: finish when all original workers have exited this._finishRun() } }) diff --git a/package.json b/package.json index d58a4da6c..1b287d189 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "repository": "Codeception/codeceptjs", "scripts": { - "json-server": "json-server test/data/rest/db.json --host 0.0.0.0 -p 8010 --watch -m test/data/rest/headers.js", + "test-server": "node bin/test-server.js test/data/rest/db.json --host 0.0.0.0 -p 8010", "json-server:graphql": "node test/data/graphql/index.js", "lint": "eslint bin/ examples/ lib/ test/ translations/ runok.js", "lint-fix": "eslint bin/ examples/ lib/ test/ translations/ runok.js --fix", @@ -55,7 +55,7 @@ "test:appium-other": "mocha test/helper/Appium_test.js --grep 'second'", "test:ios:appium-quick": "mocha test/helper/Appium_ios_test.js --grep 'quick'", "test:ios:appium-other": "mocha test/helper/Appium_ios_test.js --grep 'second'", - "test-app:start": "php -S 127.0.0.1:8000 -t test/data/app", + "test-app:start": "cd test/data/spa && npm run start", "test-app:stop": "kill -9 $(lsof -t -i:8000)", "test:unit:webbapi:playwright": "mocha test/helper/Playwright_test.js", "test:unit:webbapi:puppeteer": "mocha test/helper/Puppeteer_test.js", @@ -86,6 +86,7 @@ "axios": "1.11.0", "chalk": "4.1.2", "cheerio": "^1.0.0", + "chokidar": "^4.0.3", "commander": "11.1.0", "cross-spawn": "7.0.6", "css-to-xpath": "0.1.0", @@ -103,12 +104,13 @@ "joi": "17.13.3", "js-beautify": "1.15.4", "lodash.clonedeep": "4.5.0", - "lodash.shuffle": "4.2.0", "lodash.merge": "4.6.2", + "lodash.shuffle": "4.2.0", "mkdirp": "3.0.1", "mocha": "11.6.0", "monocart-coverage-reports": "2.12.6", "ms": "2.1.3", + "multer": "^2.0.2", "ora-classic": "5.4.2", "parse-function": "5.6.10", "parse5": "7.3.0", @@ -125,7 +127,7 @@ "@codeceptjs/expect-helper": "^1.0.2", "@codeceptjs/mock-request": "0.3.1", "@eslint/eslintrc": "3.3.1", - "@eslint/js": "9.31.0", + "@eslint/js": "9.34.0", "@faker-js/faker": "9.8.0", "@pollyjs/adapter-puppeteer": "6.0.6", "@pollyjs/core": "6.0.6", @@ -145,7 +147,7 @@ "eslint-plugin-import": "2.32.0", "eslint-plugin-mocha": "11.1.0", "expect": "30.0.5", - "express": "5.1.0", + "express": "^5.1.0", "globals": "16.2.0", "graphql": "16.11.0", "graphql-tag": "^2.12.6", diff --git a/runok.js b/runok.js index 07d2a0b4e..80d588e06 100755 --- a/runok.js +++ b/runok.js @@ -373,7 +373,7 @@ title: ${name} async server() { // run test server. Warning! PHP required! - await Promise.all([exec('php -S 127.0.0.1:8000 -t test/data/app'), npmRun('json-server')]) + await Promise.all([exec('php -S 127.0.0.1:8000 -t test/data/app'), npmRun('test-server')]) }, async release(releaseType = null) { diff --git a/test/acceptance/codecept.Playwright.CustomLocators.js b/test/acceptance/codecept.Playwright.CustomLocators.js new file mode 100644 index 000000000..3d4b85a69 --- /dev/null +++ b/test/acceptance/codecept.Playwright.CustomLocators.js @@ -0,0 +1,34 @@ +const { config } = require('../acceptance/codecept.Playwright') + +// Extend the base Playwright configuration to add custom locator strategies +const customLocatorConfig = { + ...config, + grep: null, // Remove grep filter to run custom locator tests + helpers: { + ...config.helpers, + Playwright: { + ...config.helpers.Playwright, + browser: process.env.BROWSER || 'chromium', + customLocatorStrategies: { + byRole: (selector, root) => { + return root.querySelector(`[role="${selector}"]`) + }, + byTestId: (selector, root) => { + return root.querySelector(`[data-testid="${selector}"]`) + }, + byDataQa: (selector, root) => { + const elements = root.querySelectorAll(`[data-qa="${selector}"]`) + return Array.from(elements) + }, + byAriaLabel: (selector, root) => { + return root.querySelector(`[aria-label="${selector}"]`) + }, + byPlaceholder: (selector, root) => { + return root.querySelector(`[placeholder="${selector}"]`) + }, + }, + }, + }, +} + +module.exports.config = customLocatorConfig diff --git a/test/acceptance/codecept.Playwright.js b/test/acceptance/codecept.Playwright.js index 7c5eb2f82..f2c6169eb 100644 --- a/test/acceptance/codecept.Playwright.js +++ b/test/acceptance/codecept.Playwright.js @@ -15,6 +15,23 @@ module.exports.config = { webkit: { ignoreHTTPSErrors: true, }, + customLocatorStrategies: { + byRole: (selector, root) => { + return root.querySelector(`[role="${selector}"]`) + }, + byTestId: (selector, root) => { + return root.querySelector(`[data-testid="${selector}"]`) + }, + byDataQa: (selector, root) => { + return root.querySelectorAll(`[data-qa="${selector}"]`) + }, + byAriaLabel: (selector, root) => { + return root.querySelector(`[aria-label="${selector}"]`) + }, + byPlaceholder: (selector, root) => { + return root.querySelector(`[placeholder="${selector}"]`) + }, + }, }, JSONResponse: { requestHelper: 'Playwright', diff --git a/test/acceptance/custom_locators_test.js b/test/acceptance/custom_locators_test.js new file mode 100644 index 000000000..0ff69d03f --- /dev/null +++ b/test/acceptance/custom_locators_test.js @@ -0,0 +1,125 @@ +const { I } = inject() + +Feature('Custom Locator Strategies - @Playwright') + +Before(() => { + I.amOnPage('/form/custom_locator_strategies') +}) + +Scenario('should find elements using byRole custom locator', ({ I }) => { + I.see('Custom Locator Test Page', { byRole: 'main' }) + I.seeElement({ byRole: 'form' }) + I.seeElement({ byRole: 'button' }) + I.seeElement({ byRole: 'navigation' }) + I.seeElement({ byRole: 'complementary' }) +}) + +Scenario('should find elements using byTestId custom locator', ({ I }) => { + I.see('Custom Locator Test Page', { byTestId: 'page-title' }) + I.seeElement({ byTestId: 'username-input' }) + I.seeElement({ byTestId: 'password-input' }) + I.seeElement({ byTestId: 'submit-button' }) + I.seeElement({ byTestId: 'cancel-button' }) + I.seeElement({ byTestId: 'info-text' }) +}) + +Scenario('should find elements using byDataQa custom locator', ({ I }) => { + I.seeElement({ byDataQa: 'test-form' }) + I.seeElement({ byDataQa: 'form-section' }) + I.seeElement({ byDataQa: 'submit-btn' }) + I.seeElement({ byDataQa: 'cancel-btn' }) + I.seeElement({ byDataQa: 'info-section' }) + I.seeElement({ byDataQa: 'nav-section' }) +}) + +Scenario('should find elements using byAriaLabel custom locator', ({ I }) => { + I.see('Custom Locator Test Page', { byAriaLabel: 'Welcome Message' }) + I.seeElement({ byAriaLabel: 'Username field' }) + I.seeElement({ byAriaLabel: 'Password field' }) + I.seeElement({ byAriaLabel: 'Submit form' }) + I.seeElement({ byAriaLabel: 'Cancel form' }) + I.seeElement({ byAriaLabel: 'Information message' }) +}) + +Scenario('should find elements using byPlaceholder custom locator', ({ I }) => { + I.seeElement({ byPlaceholder: 'Enter your username' }) + I.seeElement({ byPlaceholder: 'Enter your password' }) +}) + +Scenario('should interact with elements using custom locators', ({ I }) => { + I.fillField({ byTestId: 'username-input' }, 'testuser') + I.fillField({ byPlaceholder: 'Enter your password' }, 'password123') + + I.seeInField({ byTestId: 'username-input' }, 'testuser') + I.seeInField({ byAriaLabel: 'Password field' }, 'password123') + + I.click({ byDataQa: 'submit-btn' }) + // Form submission would normally happen here +}) + +Scenario('should handle multiple elements with byDataQa locator', ({ I }) => { + // byDataQa returns all matching elements, but interactions use the first one + I.seeElement({ byDataQa: 'form-section' }) + + // Should be able to see both form sections exist + I.executeScript(() => { + const sections = document.querySelectorAll('[data-qa="form-section"]') + if (sections.length !== 2) { + throw new Error(`Expected 2 form sections, found ${sections.length}`) + } + }) +}) + +Scenario('should work with complex selectors and mixed locator types', ({ I }) => { + // Test that custom locators work alongside standard ones + within({ byRole: 'form' }, () => { + I.seeElement({ byTestId: 'username-input' }) + I.seeElement('input[name="password"]') // Standard CSS selector + I.seeElement({ xpath: '//button[@type="submit"]' }) // Standard XPath + }) + + within({ byDataQa: 'nav-section' }, () => { + I.seeElement({ byAriaLabel: 'Home link' }) + I.seeElement({ byAriaLabel: 'About link' }) + I.seeElement({ byAriaLabel: 'Contact link' }) + }) +}) + +Scenario.skip('should fail gracefully for non-existent custom locators', async ({ I }) => { + // This should throw an error about undefined custom locator strategy + let errorThrown = false + let errorMessage = '' + + try { + await I.seeElement({ byCustomUndefined: 'test' }) + } catch (error) { + errorThrown = true + errorMessage = error.message + } + + if (!errorThrown) { + throw new Error('Should have thrown an error for undefined custom locator') + } + + if (!errorMessage.includes('Please define "customLocatorStrategies"')) { + throw new Error('Wrong error message: ' + errorMessage) + } +}) + +Scenario('should work with grabbing methods', async ({ I }) => { + const titleText = await I.grabTextFrom({ byTestId: 'page-title' }) + I.expectEqual(titleText, 'Custom Locator Test Page') + + const usernameValue = await I.grabValueFrom({ byAriaLabel: 'Username field' }) + I.expectEqual(usernameValue, '') + + I.fillField({ byPlaceholder: 'Enter your username' }, 'grabtest') + const newUsernameValue = await I.grabValueFrom({ byTestId: 'username-input' }) + I.expectEqual(newUsernameValue, 'grabtest') +}) + +Scenario('should work with waiting methods', ({ I }) => { + I.waitForElement({ byRole: 'main' }, 2) + I.waitForVisible({ byTestId: 'submit-button' }, 2) + I.waitForText('Custom Locator Test Page', 2, { byAriaLabel: 'Welcome Message' }) +}) diff --git a/test/data/app/api/post-data.php b/test/data/app/api/post-data.php new file mode 100644 index 000000000..863e2a050 --- /dev/null +++ b/test/data/app/api/post-data.php @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/test/data/app/index.php b/test/data/app/index.php index 6ab0b28a6..77b3e3829 100755 --- a/test/data/app/index.php +++ b/test/data/app/index.php @@ -1,52 +1,140 @@ 'application/javascript', + 'css' => 'text/css', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon' + ]; + + if (isset($content_types[$ext])) { + header('Content-Type: ' . $content_types[$ext]); + } + readfile($file_path); + exit; + } else { + // Asset not found + http_response_code(404); + echo "Asset not found: " . $request_uri; + exit; + } +} + +// Handle API routes +if (strpos($request_uri, '/api/') === 0) { + $api_file = __DIR__ . $request_uri; + if (file_exists($api_file)) { + include $api_file; + exit; + } else { + http_response_code(404); + echo json_encode(['error' => 'API endpoint not found']); + exit; + } +} + +// Handle POST data for React app +if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST)) { + // Store POST data in session for React app to display + session_start(); + $_SESSION['post_data'] = $_POST; + + // Redirect back to index with a flag + header('Location: /?posted=1'); + exit; +} + +// Handle special PHP routes that still need server-side processing if (!headers_sent()) header('Content-Type: text/html; charset=UTF-8'); -require_once('glue.php'); -require_once('data.php'); -require_once('controllers.php'); - -$urls = array( - '/' => 'index', - '/info' => 'info', - '/cookies' => 'cookies', - '/cookies2' => 'cookiesHeader', - '/search.*' => 'search', - '/login' => 'login', - '/redirect' => 'redirect', - '/redirect2' => 'redirect2', - '/redirect3' => 'redirect3', - '/redirect_long' => 'redirect_long', - '/redirect4' => 'redirect4', - '/redirect_params' => 'redirect_params', - '/redirect_interval' => 'redirect_interval', - '/redirect_header_interval' => 'redirect_header_interval', - '/redirect_self' => 'redirect_self', - '/relative_redirect' => 'redirect_relative', - '/relative/redirect' => 'redirect_relative', - '/redirect_twice' => 'redirect_twice', - '/relative/info' => 'info', - '/somepath/redirect_base_uri_has_path' => 'redirect_base_uri_has_path', - '/somepath/redirect_base_uri_has_path_302' => 'redirect_base_uri_has_path_302', - '/somepath/info' => 'info', - '/facebook\??.*' => 'facebookController', - '/form/(.*?)(#|\?.*?)?' => 'form', - '/articles\??.*' => 'articles', - '/auth' => 'httpAuth', - '/register' => 'register', - '/content-iso' => 'contentType1', - '/content-cp1251' => 'contentType2', - '/unset-cookie' => 'unsetCookie', - '/external_url' => 'external_url', - '/spinner' => 'spinner', - '/iframe' => 'iframe', - '/iframes' => 'iframes', - '/iframe_nested' => 'iframe_nested', - '/dynamic' => 'dynamic', - '/timeout' => 'timeout', - '/download' => 'download', - '/basic_auth' => 'basic_auth', - '/image' => 'basic_image', - '/invisible_elements' => 'invisible_elements' -); - -glue::stick($urls); +// Routes that need special PHP processing +$special_routes = [ + '/cookies', '/cookies2', '/login', '/auth', '/register', + '/content-iso', '/content-cp1251', '/unset-cookie', + '/download', '/basic_auth', '/redirect', '/redirect2', + '/redirect3', '/redirect_long', '/redirect4', + '/redirect_params', '/redirect_interval', + '/redirect_header_interval', '/redirect_self', + '/relative_redirect', '/relative/redirect', '/redirect_twice', + '/somepath/redirect_base_uri_has_path', + '/somepath/redirect_base_uri_has_path_302', + '/facebook', '/articles', '/external_url', + '/iframe', '/iframes', '/iframe_nested', '/dynamic', + '/timeout', '/image', '/invisible_elements' +]; + +foreach ($special_routes as $route) { + if (strpos($request_uri, $route) === 0) { + // Load the original PHP routing for these special cases + require_once('glue.php'); + require_once('data.php'); + require_once('controllers.php'); + + $urls = array( + '/' => 'index', + '/info' => 'info', + '/cookies' => 'cookies', + '/cookies2' => 'cookiesHeader', + '/search.*' => 'search', + '/login' => 'login', + '/redirect' => 'redirect', + '/redirect2' => 'redirect2', + '/redirect3' => 'redirect3', + '/redirect_long' => 'redirect_long', + '/redirect4' => 'redirect4', + '/redirect_params' => 'redirect_params', + '/redirect_interval' => 'redirect_interval', + '/redirect_header_interval' => 'redirect_header_interval', + '/redirect_self' => 'redirect_self', + '/relative_redirect' => 'redirect_relative', + '/relative/redirect' => 'redirect_relative', + '/redirect_twice' => 'redirect_twice', + '/relative/info' => 'info', + '/somepath/redirect_base_uri_has_path' => 'redirect_base_uri_has_path', + '/somepath/redirect_base_uri_has_path_302' => 'redirect_base_uri_has_path_302', + '/somepath/info' => 'info', + '/facebook\??.*' => 'facebookController', + '/form/(.*?)(#|\?.*?)?' => 'form', + '/articles\??.*' => 'articles', + '/auth' => 'httpAuth', + '/register' => 'register', + '/content-iso' => 'contentType1', + '/content-cp1251' => 'contentType2', + '/unset-cookie' => 'unsetCookie', + '/external_url' => 'external_url', + '/spinner' => 'spinner', + '/iframe' => 'iframe', + '/iframes' => 'iframes', + '/iframe_nested' => 'iframe_nested', + '/dynamic' => 'dynamic', + '/timeout' => 'timeout', + '/download' => 'download', + '/basic_auth' => 'basic_auth', + '/image' => 'basic_image', + '/invisible_elements' => 'invisible_elements' + ); + + glue::stick($urls); + exit; + } +} + +// For all other routes (React SPA routes), serve the index.html +$index_file = $spa_path . '/index.html'; +if (file_exists($index_file)) { + readfile($index_file); +} else { + echo "React SPA not built. Please run 'npm run build' in test/data/spa directory."; +} diff --git a/test/data/app/view/form/custom_locator_strategies.php b/test/data/app/view/form/custom_locator_strategies.php new file mode 100644 index 000000000..069a37d33 --- /dev/null +++ b/test/data/app/view/form/custom_locator_strategies.php @@ -0,0 +1,66 @@ + + +
+

Custom Locator Test Page

+ +
+
+ + +
+ +
+ + +
+ + + + +
+ +
+

+ This page tests custom locator strategies. +

+
+ + +
+ + diff --git a/test/data/app/view/index.php b/test/data/app/view/index.php index 1c42159ff..e6f14617d 100755 --- a/test/data/app/view/index.php +++ b/test/data/app/view/index.php @@ -3,32 +3,30 @@

Welcome to test app!

-

With special space chars

- -
+

With special space chars

+

- More info + More info

-
- Test Link + -
- Test +
+ Test
-
- Document-Relative Link + -
- Spinner + -
- Hidden input +
+ Hidden input
- A wise man said: "debug!" diff --git a/test/data/graphql/index.js b/test/data/graphql/index.js index 96dfa9b3d..86680c867 100644 --- a/test/data/graphql/index.js +++ b/test/data/graphql/index.js @@ -1,26 +1,28 @@ -const path = require('path'); -const jsonServer = require('json-server'); -const { ApolloServer } = require('@apollo/server'); -const { startStandaloneServer } = require('@apollo/server/standalone'); -const { resolvers, typeDefs } = require('./schema'); +const path = require('path') +const jsonServer = require('json-server') +const { ApolloServer } = require('@apollo/server') +const { startStandaloneServer } = require('@apollo/server/standalone') +const { resolvers, typeDefs } = require('./schema') -const TestHelper = require('../../support/TestHelper'); +const TestHelper = require('../../support/TestHelper') -const PORT = TestHelper.graphQLServerPort(); +const PORT = TestHelper.graphQLServerPort() -const app = jsonServer.create(); -const router = jsonServer.router(path.join(__dirname, 'db.json')); -const middleware = jsonServer.defaults(); +// Note: json-server components below are not actually used in this GraphQL server +// They are imported but not connected to the Apollo server +const app = jsonServer.create() +const router = jsonServer.router(path.join(__dirname, 'db.json')) +const middleware = jsonServer.defaults() const server = new ApolloServer({ typeDefs, resolvers, playground: true, -}); +}) -const res = startStandaloneServer(server, { listen: { port: PORT } }); +const res = startStandaloneServer(server, { listen: { port: PORT } }) res.then(({ url }) => { - console.log(`test graphQL server listening on ${url}...`); -}); + console.log(`test graphQL server listening on ${url}...`) +}) -module.exports = res; +module.exports = res diff --git a/test/data/rest/db.json b/test/data/rest/db.json index ad6f29c4d..4930c5ac1 100644 --- a/test/data/rest/db.json +++ b/test/data/rest/db.json @@ -1,13 +1 @@ -{ - "posts": [ - { - "id": 1, - "title": "json-server", - "author": "davert" - } - ], - "user": { - "name": "john", - "password": "123456" - } -} \ No newline at end of file +{"posts":[{"id":1,"title":"json-server","author":"davert"}],"user":{"name":"davert"}} \ No newline at end of file diff --git a/test/data/sandbox/codecept.bdd.boolean-masking.js b/test/data/sandbox/codecept.bdd.boolean-masking.js new file mode 100644 index 000000000..8b4d13d86 --- /dev/null +++ b/test/data/sandbox/codecept.bdd.boolean-masking.js @@ -0,0 +1,20 @@ +exports.config = { + tests: './*_no_test.js', + timeout: 10000, + output: './output', + helpers: { + BDD: { + require: './support/bdd_helper.js', + }, + }, + // Traditional boolean masking configuration + maskSensitiveData: true, + gherkin: { + features: './features/secret.feature', + steps: ['./features/step_definitions/my_steps.js', './features/step_definitions/my_other_steps.js'], + }, + include: {}, + bootstrap: false, + mocha: {}, + name: 'sandbox-boolean-masking', +} diff --git a/test/data/sandbox/codecept.bdd.masking.js b/test/data/sandbox/codecept.bdd.masking.js new file mode 100644 index 000000000..d66276ae0 --- /dev/null +++ b/test/data/sandbox/codecept.bdd.masking.js @@ -0,0 +1,39 @@ +exports.config = { + tests: './*_no_test.js', + timeout: 10000, + output: './output', + helpers: { + BDD: { + require: './support/bdd_helper.js', + }, + }, + // New masking configuration with custom patterns + maskSensitiveData: { + enabled: true, + patterns: [ + { + name: 'Email', + regex: /(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b)/gi, + mask: '[MASKED_EMAIL]', + }, + { + name: 'Credit Card', + regex: /\b(?:\d{4}[- ]?){3}\d{4}\b/g, + mask: '[MASKED_CARD]', + }, + { + name: 'Phone', + regex: /(\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})/g, + mask: '[MASKED_PHONE]', + }, + ], + }, + gherkin: { + features: './features/masking.feature', + steps: ['./features/step_definitions/my_steps.js', './features/step_definitions/my_other_steps.js'], + }, + include: {}, + bootstrap: false, + mocha: {}, + name: 'sandbox-masking', +} diff --git a/test/data/sandbox/configs/html-reporter-plugin/artifacts_test.js b/test/data/sandbox/configs/html-reporter-plugin/artifacts_test.js new file mode 100644 index 000000000..ea647cbd8 --- /dev/null +++ b/test/data/sandbox/configs/html-reporter-plugin/artifacts_test.js @@ -0,0 +1,19 @@ +Feature('HTML Reporter with Artifacts Test') + +Scenario('test with artifacts', async ({ I }) => { + I.amInPath('.') + I.seeFile('codecept.conf.js') + + // Simulate adding test artifacts + const container = require('../../../../../lib/container') + try { + const currentTest = container.mocha().currentTest + if (currentTest) { + currentTest.artifacts = currentTest.artifacts || [] + currentTest.artifacts.push('fake-screenshot-1.png') + currentTest.artifacts.push('fake-screenshot-2.png') + } + } catch (e) { + // Ignore if container not available + } +}) diff --git a/test/data/sandbox/configs/html-reporter-plugin/codecept-bdd.conf.js b/test/data/sandbox/configs/html-reporter-plugin/codecept-bdd.conf.js new file mode 100644 index 000000000..faf46f210 --- /dev/null +++ b/test/data/sandbox/configs/html-reporter-plugin/codecept-bdd.conf.js @@ -0,0 +1,31 @@ +exports.config = { + tests: './*_test.js', + timeout: 10000, + output: './output', + helpers: { + FileSystem: {}, + }, + gherkin: { + features: './features/*.feature', + steps: './step_definitions/steps.js', + }, + include: {}, + bootstrap: false, + mocha: {}, + name: 'sandbox-bdd', + plugins: { + htmlReporter: { + enabled: true, + output: './output', + reportFileName: 'bdd-report.html', + includeArtifacts: true, + showSteps: true, + showSkipped: true, + showMetadata: true, + showTags: true, + showRetries: true, + exportStats: false, + keepHistory: false, + }, + }, +} \ No newline at end of file diff --git a/test/data/sandbox/configs/html-reporter-plugin/codecept-with-history.conf.js b/test/data/sandbox/configs/html-reporter-plugin/codecept-with-history.conf.js new file mode 100644 index 000000000..8949ea5a3 --- /dev/null +++ b/test/data/sandbox/configs/html-reporter-plugin/codecept-with-history.conf.js @@ -0,0 +1,27 @@ +exports.config = { + tests: './*_test.js', + output: './output', + helpers: { + FileSystem: {}, + }, + include: {}, + bootstrap: false, + plugins: { + htmlReporter: { + enabled: true, + output: './output', + reportFileName: 'report.html', + includeArtifacts: true, + showSteps: true, + showSkipped: true, + showMetadata: true, + showTags: true, + showRetries: true, + keepHistory: true, + historyPath: './test-history.json', + maxHistoryEntries: 10, + }, + }, + mocha: {}, + name: 'html-reporter-plugin tests with history', +} diff --git a/test/data/sandbox/configs/html-reporter-plugin/codecept-with-stats.conf.js b/test/data/sandbox/configs/html-reporter-plugin/codecept-with-stats.conf.js new file mode 100644 index 000000000..a64c5c2d3 --- /dev/null +++ b/test/data/sandbox/configs/html-reporter-plugin/codecept-with-stats.conf.js @@ -0,0 +1,26 @@ +exports.config = { + tests: './*_test.js', + output: './output', + helpers: { + FileSystem: {}, + }, + include: {}, + bootstrap: false, + plugins: { + htmlReporter: { + enabled: true, + output: './output', + reportFileName: 'report.html', + includeArtifacts: true, + showSteps: true, + showSkipped: true, + showMetadata: true, + showTags: true, + showRetries: true, + exportStats: true, + exportStatsPath: './test-stats.json', + }, + }, + mocha: {}, + name: 'html-reporter-plugin tests with stats', +} diff --git a/test/data/sandbox/configs/html-reporter-plugin/codecept.conf.js b/test/data/sandbox/configs/html-reporter-plugin/codecept.conf.js new file mode 100644 index 000000000..61e085e6c --- /dev/null +++ b/test/data/sandbox/configs/html-reporter-plugin/codecept.conf.js @@ -0,0 +1,21 @@ +exports.config = { + tests: './*_test.js', + output: './output', + helpers: { + FileSystem: {}, + }, + include: {}, + bootstrap: false, + plugins: { + htmlReporter: { + enabled: true, + output: './output', + reportFileName: 'report.html', + includeArtifacts: true, + showSteps: true, + showSkipped: true, + }, + }, + mocha: {}, + name: 'html-reporter-plugin tests', +} \ No newline at end of file diff --git a/test/data/sandbox/configs/html-reporter-plugin/features/html-reporter.feature b/test/data/sandbox/configs/html-reporter-plugin/features/html-reporter.feature new file mode 100644 index 000000000..b275314b3 --- /dev/null +++ b/test/data/sandbox/configs/html-reporter-plugin/features/html-reporter.feature @@ -0,0 +1,29 @@ +@html-reporter @smoke +Feature: HTML Reporter BDD Test + In order to verify BDD support in HTML reporter + As a developer + I want to see properly formatted Gherkin scenarios + + Background: + Given I setup the test environment + + @important + Scenario: Basic BDD test scenario + Given I have a basic setup + When I perform an action + Then I should see the expected result + And everything should work correctly + + @regression @critical + Scenario: Test with data table + Given I have the following items: + | name | price | + | Item 1 | 10 | + | Item 2 | 20 | + When I process the items + Then the total should be 30 + + Scenario: Test that will fail + Given I have a setup that will fail + When I perform a failing action + Then this step will not be reached \ No newline at end of file diff --git a/test/data/sandbox/configs/html-reporter-plugin/html-reporter_test.js b/test/data/sandbox/configs/html-reporter-plugin/html-reporter_test.js new file mode 100644 index 000000000..1ec50a97d --- /dev/null +++ b/test/data/sandbox/configs/html-reporter-plugin/html-reporter_test.js @@ -0,0 +1,16 @@ +Feature('HTML Reporter Test') + +Scenario('test with multiple steps', ({ I }) => { + I.amInPath('.') + I.seeFile('package.json') +}) + +Scenario('test that will fail', ({ I }) => { + I.amInPath('.') + I.seeFile('this-file-should-not-exist.txt') +}) + +Scenario('test that will pass', ({ I }) => { + I.amInPath('.') + I.seeFile('codecept.conf.js') +}) \ No newline at end of file diff --git a/test/data/sandbox/configs/html-reporter-plugin/package.json b/test/data/sandbox/configs/html-reporter-plugin/package.json new file mode 100644 index 000000000..d82476379 --- /dev/null +++ b/test/data/sandbox/configs/html-reporter-plugin/package.json @@ -0,0 +1,11 @@ +{ + "name": "html-reporter-plugin-test", + "version": "1.0.0", + "description": "Test package for HTML reporter plugin tests", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC" +} diff --git a/test/data/sandbox/configs/html-reporter-plugin/step_definitions/steps.js b/test/data/sandbox/configs/html-reporter-plugin/step_definitions/steps.js new file mode 100644 index 000000000..3696fcbf7 --- /dev/null +++ b/test/data/sandbox/configs/html-reporter-plugin/step_definitions/steps.js @@ -0,0 +1,46 @@ +const { I } = inject() + +Given('I setup the test environment', () => { + console.log('Setting up test environment') +}) + +Given('I have a basic setup', () => { + console.log('Basic setup completed') +}) + +When('I perform an action', () => { + console.log('Performing action') +}) + +Then('I should see the expected result', () => { + console.log('Expected result verified') +}) + +Then('everything should work correctly', () => { + console.log('Everything working correctly') +}) + +Given('I have the following items:', (table) => { + const data = table.parse() + console.log('Items:', data) +}) + +When('I process the items', () => { + console.log('Processing items') +}) + +Then('the total should be {int}', (total) => { + console.log('Total verified:', total) +}) + +Given('I have a setup that will fail', () => { + console.log('Setup that will fail') +}) + +When('I perform a failing action', () => { + throw new Error('This is an intentional failure for testing') +}) + +Then('this step will not be reached', () => { + console.log('This should not be reached') +}) \ No newline at end of file diff --git a/test/data/sandbox/features/masking.feature b/test/data/sandbox/features/masking.feature new file mode 100644 index 000000000..17dc0422e --- /dev/null +++ b/test/data/sandbox/features/masking.feature @@ -0,0 +1,8 @@ +Feature: Custom Data Masking + + Scenario: mask custom sensitive data in output + Given I have user email "john.doe@example.com" + And I have credit card "4111 1111 1111 1111" + And I have phone number "+1-555-123-4567" + When I process user data + Then I should see masked output \ No newline at end of file diff --git a/test/data/sandbox/features/step_definitions/my_steps.js b/test/data/sandbox/features/step_definitions/my_steps.js index 5c9cc2973..da49cb6d1 100644 --- a/test/data/sandbox/features/step_definitions/my_steps.js +++ b/test/data/sandbox/features/step_definitions/my_steps.js @@ -35,6 +35,29 @@ Given('I login', () => { I.login('user', secret('password')) }) +Given('I have user email {string}', email => { + I.debug(`User email is: ${email}`) + I.say(`Processing email: ${email}`) +}) + +Given('I have credit card {string}', card => { + I.debug(`Credit card is: ${card}`) + I.say(`Processing card: ${card}`) +}) + +Given('I have phone number {string}', phone => { + I.debug(`Phone number is: ${phone}`) + I.say(`Processing phone: ${phone}`) +}) + +When('I process user data', () => { + I.debug('Processing user data with sensitive information') +}) + +Then('I should see masked output', () => { + I.debug('All sensitive data should be masked in output') +}) + Given(/^I have this product in my cart$/, table => { let str = '' for (const id in table.rows) { diff --git a/test/data/sandbox/support/bdd_helper.js b/test/data/sandbox/support/bdd_helper.js index b051d30e0..c1846c365 100644 --- a/test/data/sandbox/support/bdd_helper.js +++ b/test/data/sandbox/support/bdd_helper.js @@ -1,45 +1,57 @@ -const assert = require('assert'); -const Helper = require('../../../../lib/helper'); +const assert = require('assert') +const Helper = require('../../../../lib/helper') class CheckoutHelper extends Helper { _before() { - this.num = 0; - this.sum = 0; - this.discountCalc = null; + this.num = 0 + this.sum = 0 + this.discountCalc = null } addItem(price) { - this.num++; - this.sum += price; + this.num++ + this.sum += price } seeNum(num) { - assert.equal(num, this.num); + assert.equal(num, this.num) } seeSum(sum) { - assert.equal(sum, this.sum); + assert.equal(sum, this.sum) } haveDiscountForPrice(price, discount) { this.discountCalc = () => { if (this.sum > price) { - this.sum -= this.sum * discount / 100; + this.sum -= (this.sum * discount) / 100 } - }; + } } addProduct(name, price) { - this.sum += price; + this.sum += price } checkout() { if (this.discountCalc) { - this.discountCalc(); + this.discountCalc() } } login() {} + + say(message) { + // Use CodeceptJS output system instead of direct console.log + const output = require('../../../../lib/output') + output.log(`[Helper] ${message}`) + } + + debug(message) { + // Use CodeceptJS output system instead of direct console.log + const output = require('../../../../lib/output') + output.debug(`[Helper] ${message}`) + } } -module.exports = CheckoutHelper; +module.exports = CheckoutHelper diff --git a/test/data/spa/dist/avatar.jpg b/test/data/spa/dist/avatar.jpg new file mode 100755 index 000000000..8a42fcc19 Binary files /dev/null and b/test/data/spa/dist/avatar.jpg differ diff --git a/test/data/spa/dist/bundle.js b/test/data/spa/dist/bundle.js new file mode 100644 index 000000000..f9d7bbdb4 --- /dev/null +++ b/test/data/spa/dist/bundle.js @@ -0,0 +1,27659 @@ +var CodeceptApp = (() => { + var __create = Object.create; + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __getProtoOf = Object.getPrototypeOf; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod + )); + + // node_modules/react/cjs/react.development.js + var require_react_development = __commonJS({ + "node_modules/react/cjs/react.development.js"(exports, module) { + "use strict"; + if (true) { + (function() { + "use strict"; + if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ !== "undefined" && typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart === "function") { + __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error()); + } + var ReactVersion = "18.3.1"; + var REACT_ELEMENT_TYPE = Symbol.for("react.element"); + var REACT_PORTAL_TYPE = Symbol.for("react.portal"); + var REACT_FRAGMENT_TYPE = Symbol.for("react.fragment"); + var REACT_STRICT_MODE_TYPE = Symbol.for("react.strict_mode"); + var REACT_PROFILER_TYPE = Symbol.for("react.profiler"); + var REACT_PROVIDER_TYPE = Symbol.for("react.provider"); + var REACT_CONTEXT_TYPE = Symbol.for("react.context"); + var REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref"); + var REACT_SUSPENSE_TYPE = Symbol.for("react.suspense"); + var REACT_SUSPENSE_LIST_TYPE = Symbol.for("react.suspense_list"); + var REACT_MEMO_TYPE = Symbol.for("react.memo"); + var REACT_LAZY_TYPE = Symbol.for("react.lazy"); + var REACT_OFFSCREEN_TYPE = Symbol.for("react.offscreen"); + var MAYBE_ITERATOR_SYMBOL = Symbol.iterator; + var FAUX_ITERATOR_SYMBOL = "@@iterator"; + function getIteratorFn(maybeIterable) { + if (maybeIterable === null || typeof maybeIterable !== "object") { + return null; + } + var maybeIterator = MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL] || maybeIterable[FAUX_ITERATOR_SYMBOL]; + if (typeof maybeIterator === "function") { + return maybeIterator; + } + return null; + } + var ReactCurrentDispatcher = { + /** + * @internal + * @type {ReactComponent} + */ + current: null + }; + var ReactCurrentBatchConfig = { + transition: null + }; + var ReactCurrentActQueue = { + current: null, + // Used to reproduce behavior of `batchedUpdates` in legacy mode. + isBatchingLegacy: false, + didScheduleLegacyUpdate: false + }; + var ReactCurrentOwner = { + /** + * @internal + * @type {ReactComponent} + */ + current: null + }; + var ReactDebugCurrentFrame = {}; + var currentExtraStackFrame = null; + function setExtraStackFrame(stack) { + { + currentExtraStackFrame = stack; + } + } + { + ReactDebugCurrentFrame.setExtraStackFrame = function(stack) { + { + currentExtraStackFrame = stack; + } + }; + ReactDebugCurrentFrame.getCurrentStack = null; + ReactDebugCurrentFrame.getStackAddendum = function() { + var stack = ""; + if (currentExtraStackFrame) { + stack += currentExtraStackFrame; + } + var impl = ReactDebugCurrentFrame.getCurrentStack; + if (impl) { + stack += impl() || ""; + } + return stack; + }; + } + var enableScopeAPI = false; + var enableCacheElement = false; + var enableTransitionTracing = false; + var enableLegacyHidden = false; + var enableDebugTracing = false; + var ReactSharedInternals = { + ReactCurrentDispatcher, + ReactCurrentBatchConfig, + ReactCurrentOwner + }; + { + ReactSharedInternals.ReactDebugCurrentFrame = ReactDebugCurrentFrame; + ReactSharedInternals.ReactCurrentActQueue = ReactCurrentActQueue; + } + function warn(format) { + { + { + for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + printWarning("warn", format, args); + } + } + } + function error(format) { + { + { + for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + args[_key2 - 1] = arguments[_key2]; + } + printWarning("error", format, args); + } + } + } + function printWarning(level, format, args) { + { + var ReactDebugCurrentFrame2 = ReactSharedInternals.ReactDebugCurrentFrame; + var stack = ReactDebugCurrentFrame2.getStackAddendum(); + if (stack !== "") { + format += "%s"; + args = args.concat([stack]); + } + var argsWithFormat = args.map(function(item) { + return String(item); + }); + argsWithFormat.unshift("Warning: " + format); + Function.prototype.apply.call(console[level], console, argsWithFormat); + } + } + var didWarnStateUpdateForUnmountedComponent = {}; + function warnNoop(publicInstance, callerName) { + { + var _constructor = publicInstance.constructor; + var componentName = _constructor && (_constructor.displayName || _constructor.name) || "ReactClass"; + var warningKey = componentName + "." + callerName; + if (didWarnStateUpdateForUnmountedComponent[warningKey]) { + return; + } + error("Can't call %s on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the %s component.", callerName, componentName); + didWarnStateUpdateForUnmountedComponent[warningKey] = true; + } + } + var ReactNoopUpdateQueue = { + /** + * Checks whether or not this composite component is mounted. + * @param {ReactClass} publicInstance The instance we want to test. + * @return {boolean} True if mounted, false otherwise. + * @protected + * @final + */ + isMounted: function(publicInstance) { + return false; + }, + /** + * Forces an update. This should only be invoked when it is known with + * certainty that we are **not** in a DOM transaction. + * + * You may want to call this when you know that some deeper aspect of the + * component's state has changed but `setState` was not called. + * + * This will not invoke `shouldComponentUpdate`, but it will invoke + * `componentWillUpdate` and `componentDidUpdate`. + * + * @param {ReactClass} publicInstance The instance that should rerender. + * @param {?function} callback Called after component is updated. + * @param {?string} callerName name of the calling function in the public API. + * @internal + */ + enqueueForceUpdate: function(publicInstance, callback, callerName) { + warnNoop(publicInstance, "forceUpdate"); + }, + /** + * Replaces all of the state. Always use this or `setState` to mutate state. + * You should treat `this.state` as immutable. + * + * There is no guarantee that `this.state` will be immediately updated, so + * accessing `this.state` after calling this method may return the old value. + * + * @param {ReactClass} publicInstance The instance that should rerender. + * @param {object} completeState Next state. + * @param {?function} callback Called after component is updated. + * @param {?string} callerName name of the calling function in the public API. + * @internal + */ + enqueueReplaceState: function(publicInstance, completeState, callback, callerName) { + warnNoop(publicInstance, "replaceState"); + }, + /** + * Sets a subset of the state. This only exists because _pendingState is + * internal. This provides a merging strategy that is not available to deep + * properties which is confusing. TODO: Expose pendingState or don't use it + * during the merge. + * + * @param {ReactClass} publicInstance The instance that should rerender. + * @param {object} partialState Next partial state to be merged with state. + * @param {?function} callback Called after component is updated. + * @param {?string} Name of the calling function in the public API. + * @internal + */ + enqueueSetState: function(publicInstance, partialState, callback, callerName) { + warnNoop(publicInstance, "setState"); + } + }; + var assign = Object.assign; + var emptyObject = {}; + { + Object.freeze(emptyObject); + } + function Component2(props, context, updater) { + this.props = props; + this.context = context; + this.refs = emptyObject; + this.updater = updater || ReactNoopUpdateQueue; + } + Component2.prototype.isReactComponent = {}; + Component2.prototype.setState = function(partialState, callback) { + if (typeof partialState !== "object" && typeof partialState !== "function" && partialState != null) { + throw new Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables."); + } + this.updater.enqueueSetState(this, partialState, callback, "setState"); + }; + Component2.prototype.forceUpdate = function(callback) { + this.updater.enqueueForceUpdate(this, callback, "forceUpdate"); + }; + { + var deprecatedAPIs = { + isMounted: ["isMounted", "Instead, make sure to clean up subscriptions and pending requests in componentWillUnmount to prevent memory leaks."], + replaceState: ["replaceState", "Refactor your code to use setState instead (see https://github.com/facebook/react/issues/3236)."] + }; + var defineDeprecationWarning = function(methodName, info) { + Object.defineProperty(Component2.prototype, methodName, { + get: function() { + warn("%s(...) is deprecated in plain JavaScript React classes. %s", info[0], info[1]); + return void 0; + } + }); + }; + for (var fnName in deprecatedAPIs) { + if (deprecatedAPIs.hasOwnProperty(fnName)) { + defineDeprecationWarning(fnName, deprecatedAPIs[fnName]); + } + } + } + function ComponentDummy() { + } + ComponentDummy.prototype = Component2.prototype; + function PureComponent(props, context, updater) { + this.props = props; + this.context = context; + this.refs = emptyObject; + this.updater = updater || ReactNoopUpdateQueue; + } + var pureComponentPrototype = PureComponent.prototype = new ComponentDummy(); + pureComponentPrototype.constructor = PureComponent; + assign(pureComponentPrototype, Component2.prototype); + pureComponentPrototype.isPureReactComponent = true; + function createRef() { + var refObject = { + current: null + }; + { + Object.seal(refObject); + } + return refObject; + } + var isArrayImpl = Array.isArray; + function isArray(a) { + return isArrayImpl(a); + } + function typeName(value) { + { + var hasToStringTag = typeof Symbol === "function" && Symbol.toStringTag; + var type = hasToStringTag && value[Symbol.toStringTag] || value.constructor.name || "Object"; + return type; + } + } + function willCoercionThrow(value) { + { + try { + testStringCoercion(value); + return false; + } catch (e) { + return true; + } + } + } + function testStringCoercion(value) { + return "" + value; + } + function checkKeyStringCoercion(value) { + { + if (willCoercionThrow(value)) { + error("The provided key is an unsupported type %s. This value must be coerced to a string before before using it here.", typeName(value)); + return testStringCoercion(value); + } + } + } + function getWrappedName(outerType, innerType, wrapperName) { + var displayName = outerType.displayName; + if (displayName) { + return displayName; + } + var functionName = innerType.displayName || innerType.name || ""; + return functionName !== "" ? wrapperName + "(" + functionName + ")" : wrapperName; + } + function getContextName(type) { + return type.displayName || "Context"; + } + function getComponentNameFromType(type) { + if (type == null) { + return null; + } + { + if (typeof type.tag === "number") { + error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."); + } + } + if (typeof type === "function") { + return type.displayName || type.name || null; + } + if (typeof type === "string") { + return type; + } + switch (type) { + case REACT_FRAGMENT_TYPE: + return "Fragment"; + case REACT_PORTAL_TYPE: + return "Portal"; + case REACT_PROFILER_TYPE: + return "Profiler"; + case REACT_STRICT_MODE_TYPE: + return "StrictMode"; + case REACT_SUSPENSE_TYPE: + return "Suspense"; + case REACT_SUSPENSE_LIST_TYPE: + return "SuspenseList"; + } + if (typeof type === "object") { + switch (type.$$typeof) { + case REACT_CONTEXT_TYPE: + var context = type; + return getContextName(context) + ".Consumer"; + case REACT_PROVIDER_TYPE: + var provider = type; + return getContextName(provider._context) + ".Provider"; + case REACT_FORWARD_REF_TYPE: + return getWrappedName(type, type.render, "ForwardRef"); + case REACT_MEMO_TYPE: + var outerName = type.displayName || null; + if (outerName !== null) { + return outerName; + } + return getComponentNameFromType(type.type) || "Memo"; + case REACT_LAZY_TYPE: { + var lazyComponent = type; + var payload = lazyComponent._payload; + var init = lazyComponent._init; + try { + return getComponentNameFromType(init(payload)); + } catch (x) { + return null; + } + } + } + } + return null; + } + var hasOwnProperty = Object.prototype.hasOwnProperty; + var RESERVED_PROPS = { + key: true, + ref: true, + __self: true, + __source: true + }; + var specialPropKeyWarningShown, specialPropRefWarningShown, didWarnAboutStringRefs; + { + didWarnAboutStringRefs = {}; + } + function hasValidRef(config) { + { + if (hasOwnProperty.call(config, "ref")) { + var getter = Object.getOwnPropertyDescriptor(config, "ref").get; + if (getter && getter.isReactWarning) { + return false; + } + } + } + return config.ref !== void 0; + } + function hasValidKey(config) { + { + if (hasOwnProperty.call(config, "key")) { + var getter = Object.getOwnPropertyDescriptor(config, "key").get; + if (getter && getter.isReactWarning) { + return false; + } + } + } + return config.key !== void 0; + } + function defineKeyPropWarningGetter(props, displayName) { + var warnAboutAccessingKey = function() { + { + if (!specialPropKeyWarningShown) { + specialPropKeyWarningShown = true; + error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)", displayName); + } + } + }; + warnAboutAccessingKey.isReactWarning = true; + Object.defineProperty(props, "key", { + get: warnAboutAccessingKey, + configurable: true + }); + } + function defineRefPropWarningGetter(props, displayName) { + var warnAboutAccessingRef = function() { + { + if (!specialPropRefWarningShown) { + specialPropRefWarningShown = true; + error("%s: `ref` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)", displayName); + } + } + }; + warnAboutAccessingRef.isReactWarning = true; + Object.defineProperty(props, "ref", { + get: warnAboutAccessingRef, + configurable: true + }); + } + function warnIfStringRefCannotBeAutoConverted(config) { + { + if (typeof config.ref === "string" && ReactCurrentOwner.current && config.__self && ReactCurrentOwner.current.stateNode !== config.__self) { + var componentName = getComponentNameFromType(ReactCurrentOwner.current.type); + if (!didWarnAboutStringRefs[componentName]) { + error('Component "%s" contains the string ref "%s". Support for string refs will be removed in a future major release. This case cannot be automatically converted to an arrow function. We ask you to manually fix this case by using useRef() or createRef() instead. Learn more about using refs safely here: https://reactjs.org/link/strict-mode-string-ref', componentName, config.ref); + didWarnAboutStringRefs[componentName] = true; + } + } + } + } + var ReactElement = function(type, key, ref, self, source, owner, props) { + var element = { + // This tag allows us to uniquely identify this as a React Element + $$typeof: REACT_ELEMENT_TYPE, + // Built-in properties that belong on the element + type, + key, + ref, + props, + // Record the component responsible for creating this element. + _owner: owner + }; + { + element._store = {}; + Object.defineProperty(element._store, "validated", { + configurable: false, + enumerable: false, + writable: true, + value: false + }); + Object.defineProperty(element, "_self", { + configurable: false, + enumerable: false, + writable: false, + value: self + }); + Object.defineProperty(element, "_source", { + configurable: false, + enumerable: false, + writable: false, + value: source + }); + if (Object.freeze) { + Object.freeze(element.props); + Object.freeze(element); + } + } + return element; + }; + function createElement3(type, config, children) { + var propName; + var props = {}; + var key = null; + var ref = null; + var self = null; + var source = null; + if (config != null) { + if (hasValidRef(config)) { + ref = config.ref; + { + warnIfStringRefCannotBeAutoConverted(config); + } + } + if (hasValidKey(config)) { + { + checkKeyStringCoercion(config.key); + } + key = "" + config.key; + } + self = config.__self === void 0 ? null : config.__self; + source = config.__source === void 0 ? null : config.__source; + for (propName in config) { + if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) { + props[propName] = config[propName]; + } + } + } + var childrenLength = arguments.length - 2; + if (childrenLength === 1) { + props.children = children; + } else if (childrenLength > 1) { + var childArray = Array(childrenLength); + for (var i = 0; i < childrenLength; i++) { + childArray[i] = arguments[i + 2]; + } + { + if (Object.freeze) { + Object.freeze(childArray); + } + } + props.children = childArray; + } + if (type && type.defaultProps) { + var defaultProps = type.defaultProps; + for (propName in defaultProps) { + if (props[propName] === void 0) { + props[propName] = defaultProps[propName]; + } + } + } + { + if (key || ref) { + var displayName = typeof type === "function" ? type.displayName || type.name || "Unknown" : type; + if (key) { + defineKeyPropWarningGetter(props, displayName); + } + if (ref) { + defineRefPropWarningGetter(props, displayName); + } + } + } + return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props); + } + function cloneAndReplaceKey(oldElement, newKey) { + var newElement = ReactElement(oldElement.type, newKey, oldElement.ref, oldElement._self, oldElement._source, oldElement._owner, oldElement.props); + return newElement; + } + function cloneElement(element, config, children) { + if (element === null || element === void 0) { + throw new Error("React.cloneElement(...): The argument must be a React element, but you passed " + element + "."); + } + var propName; + var props = assign({}, element.props); + var key = element.key; + var ref = element.ref; + var self = element._self; + var source = element._source; + var owner = element._owner; + if (config != null) { + if (hasValidRef(config)) { + ref = config.ref; + owner = ReactCurrentOwner.current; + } + if (hasValidKey(config)) { + { + checkKeyStringCoercion(config.key); + } + key = "" + config.key; + } + var defaultProps; + if (element.type && element.type.defaultProps) { + defaultProps = element.type.defaultProps; + } + for (propName in config) { + if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) { + if (config[propName] === void 0 && defaultProps !== void 0) { + props[propName] = defaultProps[propName]; + } else { + props[propName] = config[propName]; + } + } + } + } + var childrenLength = arguments.length - 2; + if (childrenLength === 1) { + props.children = children; + } else if (childrenLength > 1) { + var childArray = Array(childrenLength); + for (var i = 0; i < childrenLength; i++) { + childArray[i] = arguments[i + 2]; + } + props.children = childArray; + } + return ReactElement(element.type, key, ref, self, source, owner, props); + } + function isValidElement2(object) { + return typeof object === "object" && object !== null && object.$$typeof === REACT_ELEMENT_TYPE; + } + var SEPARATOR = "."; + var SUBSEPARATOR = ":"; + function escape(key) { + var escapeRegex = /[=:]/g; + var escaperLookup = { + "=": "=0", + ":": "=2" + }; + var escapedString = key.replace(escapeRegex, function(match) { + return escaperLookup[match]; + }); + return "$" + escapedString; + } + var didWarnAboutMaps = false; + var userProvidedKeyEscapeRegex = /\/+/g; + function escapeUserProvidedKey(text) { + return text.replace(userProvidedKeyEscapeRegex, "$&/"); + } + function getElementKey(element, index) { + if (typeof element === "object" && element !== null && element.key != null) { + { + checkKeyStringCoercion(element.key); + } + return escape("" + element.key); + } + return index.toString(36); + } + function mapIntoArray(children, array, escapedPrefix, nameSoFar, callback) { + var type = typeof children; + if (type === "undefined" || type === "boolean") { + children = null; + } + var invokeCallback = false; + if (children === null) { + invokeCallback = true; + } else { + switch (type) { + case "string": + case "number": + invokeCallback = true; + break; + case "object": + switch (children.$$typeof) { + case REACT_ELEMENT_TYPE: + case REACT_PORTAL_TYPE: + invokeCallback = true; + } + } + } + if (invokeCallback) { + var _child = children; + var mappedChild = callback(_child); + var childKey = nameSoFar === "" ? SEPARATOR + getElementKey(_child, 0) : nameSoFar; + if (isArray(mappedChild)) { + var escapedChildKey = ""; + if (childKey != null) { + escapedChildKey = escapeUserProvidedKey(childKey) + "/"; + } + mapIntoArray(mappedChild, array, escapedChildKey, "", function(c) { + return c; + }); + } else if (mappedChild != null) { + if (isValidElement2(mappedChild)) { + { + if (mappedChild.key && (!_child || _child.key !== mappedChild.key)) { + checkKeyStringCoercion(mappedChild.key); + } + } + mappedChild = cloneAndReplaceKey( + mappedChild, + // Keep both the (mapped) and old keys if they differ, just as + // traverseAllChildren used to do for objects as children + escapedPrefix + // $FlowFixMe Flow incorrectly thinks React.Portal doesn't have a key + (mappedChild.key && (!_child || _child.key !== mappedChild.key) ? ( + // $FlowFixMe Flow incorrectly thinks existing element's key can be a number + // eslint-disable-next-line react-internal/safe-string-coercion + escapeUserProvidedKey("" + mappedChild.key) + "/" + ) : "") + childKey + ); + } + array.push(mappedChild); + } + return 1; + } + var child; + var nextName; + var subtreeCount = 0; + var nextNamePrefix = nameSoFar === "" ? SEPARATOR : nameSoFar + SUBSEPARATOR; + if (isArray(children)) { + for (var i = 0; i < children.length; i++) { + child = children[i]; + nextName = nextNamePrefix + getElementKey(child, i); + subtreeCount += mapIntoArray(child, array, escapedPrefix, nextName, callback); + } + } else { + var iteratorFn = getIteratorFn(children); + if (typeof iteratorFn === "function") { + var iterableChildren = children; + { + if (iteratorFn === iterableChildren.entries) { + if (!didWarnAboutMaps) { + warn("Using Maps as children is not supported. Use an array of keyed ReactElements instead."); + } + didWarnAboutMaps = true; + } + } + var iterator = iteratorFn.call(iterableChildren); + var step; + var ii = 0; + while (!(step = iterator.next()).done) { + child = step.value; + nextName = nextNamePrefix + getElementKey(child, ii++); + subtreeCount += mapIntoArray(child, array, escapedPrefix, nextName, callback); + } + } else if (type === "object") { + var childrenString = String(children); + throw new Error("Objects are not valid as a React child (found: " + (childrenString === "[object Object]" ? "object with keys {" + Object.keys(children).join(", ") + "}" : childrenString) + "). If you meant to render a collection of children, use an array instead."); + } + } + return subtreeCount; + } + function mapChildren(children, func, context) { + if (children == null) { + return children; + } + var result = []; + var count = 0; + mapIntoArray(children, result, "", "", function(child) { + return func.call(context, child, count++); + }); + return result; + } + function countChildren(children) { + var n = 0; + mapChildren(children, function() { + n++; + }); + return n; + } + function forEachChildren(children, forEachFunc, forEachContext) { + mapChildren(children, function() { + forEachFunc.apply(this, arguments); + }, forEachContext); + } + function toArray(children) { + return mapChildren(children, function(child) { + return child; + }) || []; + } + function onlyChild(children) { + if (!isValidElement2(children)) { + throw new Error("React.Children.only expected to receive a single React element child."); + } + return children; + } + function createContext3(defaultValue) { + var context = { + $$typeof: REACT_CONTEXT_TYPE, + // As a workaround to support multiple concurrent renderers, we categorize + // some renderers as primary and others as secondary. We only expect + // there to be two concurrent renderers at most: React Native (primary) and + // Fabric (secondary); React DOM (primary) and React ART (secondary). + // Secondary renderers store their context values on separate fields. + _currentValue: defaultValue, + _currentValue2: defaultValue, + // Used to track how many concurrent renderers this context currently + // supports within in a single renderer. Such as parallel server rendering. + _threadCount: 0, + // These are circular + Provider: null, + Consumer: null, + // Add these to use same hidden class in VM as ServerContext + _defaultValue: null, + _globalName: null + }; + context.Provider = { + $$typeof: REACT_PROVIDER_TYPE, + _context: context + }; + var hasWarnedAboutUsingNestedContextConsumers = false; + var hasWarnedAboutUsingConsumerProvider = false; + var hasWarnedAboutDisplayNameOnConsumer = false; + { + var Consumer = { + $$typeof: REACT_CONTEXT_TYPE, + _context: context + }; + Object.defineProperties(Consumer, { + Provider: { + get: function() { + if (!hasWarnedAboutUsingConsumerProvider) { + hasWarnedAboutUsingConsumerProvider = true; + error("Rendering is not supported and will be removed in a future major release. Did you mean to render instead?"); + } + return context.Provider; + }, + set: function(_Provider) { + context.Provider = _Provider; + } + }, + _currentValue: { + get: function() { + return context._currentValue; + }, + set: function(_currentValue) { + context._currentValue = _currentValue; + } + }, + _currentValue2: { + get: function() { + return context._currentValue2; + }, + set: function(_currentValue2) { + context._currentValue2 = _currentValue2; + } + }, + _threadCount: { + get: function() { + return context._threadCount; + }, + set: function(_threadCount) { + context._threadCount = _threadCount; + } + }, + Consumer: { + get: function() { + if (!hasWarnedAboutUsingNestedContextConsumers) { + hasWarnedAboutUsingNestedContextConsumers = true; + error("Rendering is not supported and will be removed in a future major release. Did you mean to render instead?"); + } + return context.Consumer; + } + }, + displayName: { + get: function() { + return context.displayName; + }, + set: function(displayName) { + if (!hasWarnedAboutDisplayNameOnConsumer) { + warn("Setting `displayName` on Context.Consumer has no effect. You should set it directly on the context with Context.displayName = '%s'.", displayName); + hasWarnedAboutDisplayNameOnConsumer = true; + } + } + } + }); + context.Consumer = Consumer; + } + { + context._currentRenderer = null; + context._currentRenderer2 = null; + } + return context; + } + var Uninitialized = -1; + var Pending = 0; + var Resolved = 1; + var Rejected = 2; + function lazyInitializer(payload) { + if (payload._status === Uninitialized) { + var ctor = payload._result; + var thenable = ctor(); + thenable.then(function(moduleObject2) { + if (payload._status === Pending || payload._status === Uninitialized) { + var resolved = payload; + resolved._status = Resolved; + resolved._result = moduleObject2; + } + }, function(error2) { + if (payload._status === Pending || payload._status === Uninitialized) { + var rejected = payload; + rejected._status = Rejected; + rejected._result = error2; + } + }); + if (payload._status === Uninitialized) { + var pending = payload; + pending._status = Pending; + pending._result = thenable; + } + } + if (payload._status === Resolved) { + var moduleObject = payload._result; + { + if (moduleObject === void 0) { + error("lazy: Expected the result of a dynamic import() call. Instead received: %s\n\nYour code should look like: \n const MyComponent = lazy(() => import('./MyComponent'))\n\nDid you accidentally put curly braces around the import?", moduleObject); + } + } + { + if (!("default" in moduleObject)) { + error("lazy: Expected the result of a dynamic import() call. Instead received: %s\n\nYour code should look like: \n const MyComponent = lazy(() => import('./MyComponent'))", moduleObject); + } + } + return moduleObject.default; + } else { + throw payload._result; + } + } + function lazy(ctor) { + var payload = { + // We use these fields to store the result. + _status: Uninitialized, + _result: ctor + }; + var lazyType = { + $$typeof: REACT_LAZY_TYPE, + _payload: payload, + _init: lazyInitializer + }; + { + var defaultProps; + var propTypes; + Object.defineProperties(lazyType, { + defaultProps: { + configurable: true, + get: function() { + return defaultProps; + }, + set: function(newDefaultProps) { + error("React.lazy(...): It is not supported to assign `defaultProps` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."); + defaultProps = newDefaultProps; + Object.defineProperty(lazyType, "defaultProps", { + enumerable: true + }); + } + }, + propTypes: { + configurable: true, + get: function() { + return propTypes; + }, + set: function(newPropTypes) { + error("React.lazy(...): It is not supported to assign `propTypes` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."); + propTypes = newPropTypes; + Object.defineProperty(lazyType, "propTypes", { + enumerable: true + }); + } + } + }); + } + return lazyType; + } + function forwardRef2(render) { + { + if (render != null && render.$$typeof === REACT_MEMO_TYPE) { + error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."); + } else if (typeof render !== "function") { + error("forwardRef requires a render function but was given %s.", render === null ? "null" : typeof render); + } else { + if (render.length !== 0 && render.length !== 2) { + error("forwardRef render functions accept exactly two parameters: props and ref. %s", render.length === 1 ? "Did you forget to use the ref parameter?" : "Any additional parameter will be undefined."); + } + } + if (render != null) { + if (render.defaultProps != null || render.propTypes != null) { + error("forwardRef render functions do not support propTypes or defaultProps. Did you accidentally pass a React component?"); + } + } + } + var elementType = { + $$typeof: REACT_FORWARD_REF_TYPE, + render + }; + { + var ownName; + Object.defineProperty(elementType, "displayName", { + enumerable: false, + configurable: true, + get: function() { + return ownName; + }, + set: function(name) { + ownName = name; + if (!render.name && !render.displayName) { + render.displayName = name; + } + } + }); + } + return elementType; + } + var REACT_MODULE_REFERENCE; + { + REACT_MODULE_REFERENCE = Symbol.for("react.module.reference"); + } + function isValidElementType(type) { + if (typeof type === "string" || typeof type === "function") { + return true; + } + if (type === REACT_FRAGMENT_TYPE || type === REACT_PROFILER_TYPE || enableDebugTracing || type === REACT_STRICT_MODE_TYPE || type === REACT_SUSPENSE_TYPE || type === REACT_SUSPENSE_LIST_TYPE || enableLegacyHidden || type === REACT_OFFSCREEN_TYPE || enableScopeAPI || enableCacheElement || enableTransitionTracing) { + return true; + } + if (typeof type === "object" && type !== null) { + if (type.$$typeof === REACT_LAZY_TYPE || type.$$typeof === REACT_MEMO_TYPE || type.$$typeof === REACT_PROVIDER_TYPE || type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE || // This needs to include all possible module reference object + // types supported by any Flight configuration anywhere since + // we don't know which Flight build this will end up being used + // with. + type.$$typeof === REACT_MODULE_REFERENCE || type.getModuleId !== void 0) { + return true; + } + } + return false; + } + function memo2(type, compare) { + { + if (!isValidElementType(type)) { + error("memo: The first argument must be a component. Instead received: %s", type === null ? "null" : typeof type); + } + } + var elementType = { + $$typeof: REACT_MEMO_TYPE, + type, + compare: compare === void 0 ? null : compare + }; + { + var ownName; + Object.defineProperty(elementType, "displayName", { + enumerable: false, + configurable: true, + get: function() { + return ownName; + }, + set: function(name) { + ownName = name; + if (!type.name && !type.displayName) { + type.displayName = name; + } + } + }); + } + return elementType; + } + function resolveDispatcher() { + var dispatcher = ReactCurrentDispatcher.current; + { + if (dispatcher === null) { + error("Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:\n1. You might have mismatching versions of React and the renderer (such as React DOM)\n2. You might be breaking the Rules of Hooks\n3. You might have more than one copy of React in the same app\nSee https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem."); + } + } + return dispatcher; + } + function useContext3(Context) { + var dispatcher = resolveDispatcher(); + { + if (Context._context !== void 0) { + var realContext = Context._context; + if (realContext.Consumer === Context) { + error("Calling useContext(Context.Consumer) is not supported, may cause bugs, and will be removed in a future major release. Did you mean to call useContext(Context) instead?"); + } else if (realContext.Provider === Context) { + error("Calling useContext(Context.Provider) is not supported. Did you mean to call useContext(Context) instead?"); + } + } + } + return dispatcher.useContext(Context); + } + function useState3(initialState) { + var dispatcher = resolveDispatcher(); + return dispatcher.useState(initialState); + } + function useReducer(reducer, initialArg, init) { + var dispatcher = resolveDispatcher(); + return dispatcher.useReducer(reducer, initialArg, init); + } + function useRef3(initialValue) { + var dispatcher = resolveDispatcher(); + return dispatcher.useRef(initialValue); + } + function useEffect3(create, deps) { + var dispatcher = resolveDispatcher(); + return dispatcher.useEffect(create, deps); + } + function useInsertionEffect(create, deps) { + var dispatcher = resolveDispatcher(); + return dispatcher.useInsertionEffect(create, deps); + } + function useLayoutEffect3(create, deps) { + var dispatcher = resolveDispatcher(); + return dispatcher.useLayoutEffect(create, deps); + } + function useCallback3(callback, deps) { + var dispatcher = resolveDispatcher(); + return dispatcher.useCallback(callback, deps); + } + function useMemo3(create, deps) { + var dispatcher = resolveDispatcher(); + return dispatcher.useMemo(create, deps); + } + function useImperativeHandle(ref, create, deps) { + var dispatcher = resolveDispatcher(); + return dispatcher.useImperativeHandle(ref, create, deps); + } + function useDebugValue(value, formatterFn) { + { + var dispatcher = resolveDispatcher(); + return dispatcher.useDebugValue(value, formatterFn); + } + } + function useTransition() { + var dispatcher = resolveDispatcher(); + return dispatcher.useTransition(); + } + function useDeferredValue(value) { + var dispatcher = resolveDispatcher(); + return dispatcher.useDeferredValue(value); + } + function useId() { + var dispatcher = resolveDispatcher(); + return dispatcher.useId(); + } + function useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) { + var dispatcher = resolveDispatcher(); + return dispatcher.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + } + var disabledDepth = 0; + var prevLog; + var prevInfo; + var prevWarn; + var prevError; + var prevGroup; + var prevGroupCollapsed; + var prevGroupEnd; + function disabledLog() { + } + disabledLog.__reactDisabledLog = true; + function disableLogs() { + { + if (disabledDepth === 0) { + prevLog = console.log; + prevInfo = console.info; + prevWarn = console.warn; + prevError = console.error; + prevGroup = console.group; + prevGroupCollapsed = console.groupCollapsed; + prevGroupEnd = console.groupEnd; + var props = { + configurable: true, + enumerable: true, + value: disabledLog, + writable: true + }; + Object.defineProperties(console, { + info: props, + log: props, + warn: props, + error: props, + group: props, + groupCollapsed: props, + groupEnd: props + }); + } + disabledDepth++; + } + } + function reenableLogs() { + { + disabledDepth--; + if (disabledDepth === 0) { + var props = { + configurable: true, + enumerable: true, + writable: true + }; + Object.defineProperties(console, { + log: assign({}, props, { + value: prevLog + }), + info: assign({}, props, { + value: prevInfo + }), + warn: assign({}, props, { + value: prevWarn + }), + error: assign({}, props, { + value: prevError + }), + group: assign({}, props, { + value: prevGroup + }), + groupCollapsed: assign({}, props, { + value: prevGroupCollapsed + }), + groupEnd: assign({}, props, { + value: prevGroupEnd + }) + }); + } + if (disabledDepth < 0) { + error("disabledDepth fell below zero. This is a bug in React. Please file an issue."); + } + } + } + var ReactCurrentDispatcher$1 = ReactSharedInternals.ReactCurrentDispatcher; + var prefix; + function describeBuiltInComponentFrame(name, source, ownerFn) { + { + if (prefix === void 0) { + try { + throw Error(); + } catch (x) { + var match = x.stack.trim().match(/\n( *(at )?)/); + prefix = match && match[1] || ""; + } + } + return "\n" + prefix + name; + } + } + var reentry = false; + var componentFrameCache; + { + var PossiblyWeakMap = typeof WeakMap === "function" ? WeakMap : Map; + componentFrameCache = new PossiblyWeakMap(); + } + function describeNativeComponentFrame(fn, construct) { + if (!fn || reentry) { + return ""; + } + { + var frame = componentFrameCache.get(fn); + if (frame !== void 0) { + return frame; + } + } + var control; + reentry = true; + var previousPrepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = void 0; + var previousDispatcher; + { + previousDispatcher = ReactCurrentDispatcher$1.current; + ReactCurrentDispatcher$1.current = null; + disableLogs(); + } + try { + if (construct) { + var Fake = function() { + throw Error(); + }; + Object.defineProperty(Fake.prototype, "props", { + set: function() { + throw Error(); + } + }); + if (typeof Reflect === "object" && Reflect.construct) { + try { + Reflect.construct(Fake, []); + } catch (x) { + control = x; + } + Reflect.construct(fn, [], Fake); + } else { + try { + Fake.call(); + } catch (x) { + control = x; + } + fn.call(Fake.prototype); + } + } else { + try { + throw Error(); + } catch (x) { + control = x; + } + fn(); + } + } catch (sample) { + if (sample && control && typeof sample.stack === "string") { + var sampleLines = sample.stack.split("\n"); + var controlLines = control.stack.split("\n"); + var s = sampleLines.length - 1; + var c = controlLines.length - 1; + while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) { + c--; + } + for (; s >= 1 && c >= 0; s--, c--) { + if (sampleLines[s] !== controlLines[c]) { + if (s !== 1 || c !== 1) { + do { + s--; + c--; + if (c < 0 || sampleLines[s] !== controlLines[c]) { + var _frame = "\n" + sampleLines[s].replace(" at new ", " at "); + if (fn.displayName && _frame.includes("")) { + _frame = _frame.replace("", fn.displayName); + } + { + if (typeof fn === "function") { + componentFrameCache.set(fn, _frame); + } + } + return _frame; + } + } while (s >= 1 && c >= 0); + } + break; + } + } + } + } finally { + reentry = false; + { + ReactCurrentDispatcher$1.current = previousDispatcher; + reenableLogs(); + } + Error.prepareStackTrace = previousPrepareStackTrace; + } + var name = fn ? fn.displayName || fn.name : ""; + var syntheticFrame = name ? describeBuiltInComponentFrame(name) : ""; + { + if (typeof fn === "function") { + componentFrameCache.set(fn, syntheticFrame); + } + } + return syntheticFrame; + } + function describeFunctionComponentFrame(fn, source, ownerFn) { + { + return describeNativeComponentFrame(fn, false); + } + } + function shouldConstruct(Component3) { + var prototype = Component3.prototype; + return !!(prototype && prototype.isReactComponent); + } + function describeUnknownElementTypeFrameInDEV(type, source, ownerFn) { + if (type == null) { + return ""; + } + if (typeof type === "function") { + { + return describeNativeComponentFrame(type, shouldConstruct(type)); + } + } + if (typeof type === "string") { + return describeBuiltInComponentFrame(type); + } + switch (type) { + case REACT_SUSPENSE_TYPE: + return describeBuiltInComponentFrame("Suspense"); + case REACT_SUSPENSE_LIST_TYPE: + return describeBuiltInComponentFrame("SuspenseList"); + } + if (typeof type === "object") { + switch (type.$$typeof) { + case REACT_FORWARD_REF_TYPE: + return describeFunctionComponentFrame(type.render); + case REACT_MEMO_TYPE: + return describeUnknownElementTypeFrameInDEV(type.type, source, ownerFn); + case REACT_LAZY_TYPE: { + var lazyComponent = type; + var payload = lazyComponent._payload; + var init = lazyComponent._init; + try { + return describeUnknownElementTypeFrameInDEV(init(payload), source, ownerFn); + } catch (x) { + } + } + } + } + return ""; + } + var loggedTypeFailures = {}; + var ReactDebugCurrentFrame$1 = ReactSharedInternals.ReactDebugCurrentFrame; + function setCurrentlyValidatingElement(element) { + { + if (element) { + var owner = element._owner; + var stack = describeUnknownElementTypeFrameInDEV(element.type, element._source, owner ? owner.type : null); + ReactDebugCurrentFrame$1.setExtraStackFrame(stack); + } else { + ReactDebugCurrentFrame$1.setExtraStackFrame(null); + } + } + } + function checkPropTypes(typeSpecs, values, location, componentName, element) { + { + var has = Function.call.bind(hasOwnProperty); + for (var typeSpecName in typeSpecs) { + if (has(typeSpecs, typeSpecName)) { + var error$1 = void 0; + try { + if (typeof typeSpecs[typeSpecName] !== "function") { + var err = Error((componentName || "React class") + ": " + location + " type `" + typeSpecName + "` is invalid; it must be a function, usually from the `prop-types` package, but received `" + typeof typeSpecs[typeSpecName] + "`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`."); + err.name = "Invariant Violation"; + throw err; + } + error$1 = typeSpecs[typeSpecName](values, typeSpecName, componentName, location, null, "SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"); + } catch (ex) { + error$1 = ex; + } + if (error$1 && !(error$1 instanceof Error)) { + setCurrentlyValidatingElement(element); + error("%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).", componentName || "React class", location, typeSpecName, typeof error$1); + setCurrentlyValidatingElement(null); + } + if (error$1 instanceof Error && !(error$1.message in loggedTypeFailures)) { + loggedTypeFailures[error$1.message] = true; + setCurrentlyValidatingElement(element); + error("Failed %s type: %s", location, error$1.message); + setCurrentlyValidatingElement(null); + } + } + } + } + } + function setCurrentlyValidatingElement$1(element) { + { + if (element) { + var owner = element._owner; + var stack = describeUnknownElementTypeFrameInDEV(element.type, element._source, owner ? owner.type : null); + setExtraStackFrame(stack); + } else { + setExtraStackFrame(null); + } + } + } + var propTypesMisspellWarningShown; + { + propTypesMisspellWarningShown = false; + } + function getDeclarationErrorAddendum() { + if (ReactCurrentOwner.current) { + var name = getComponentNameFromType(ReactCurrentOwner.current.type); + if (name) { + return "\n\nCheck the render method of `" + name + "`."; + } + } + return ""; + } + function getSourceInfoErrorAddendum(source) { + if (source !== void 0) { + var fileName = source.fileName.replace(/^.*[\\\/]/, ""); + var lineNumber = source.lineNumber; + return "\n\nCheck your code at " + fileName + ":" + lineNumber + "."; + } + return ""; + } + function getSourceInfoErrorAddendumForProps(elementProps) { + if (elementProps !== null && elementProps !== void 0) { + return getSourceInfoErrorAddendum(elementProps.__source); + } + return ""; + } + var ownerHasKeyUseWarning = {}; + function getCurrentComponentErrorInfo(parentType) { + var info = getDeclarationErrorAddendum(); + if (!info) { + var parentName = typeof parentType === "string" ? parentType : parentType.displayName || parentType.name; + if (parentName) { + info = "\n\nCheck the top-level render call using <" + parentName + ">."; + } + } + return info; + } + function validateExplicitKey(element, parentType) { + if (!element._store || element._store.validated || element.key != null) { + return; + } + element._store.validated = true; + var currentComponentErrorInfo = getCurrentComponentErrorInfo(parentType); + if (ownerHasKeyUseWarning[currentComponentErrorInfo]) { + return; + } + ownerHasKeyUseWarning[currentComponentErrorInfo] = true; + var childOwner = ""; + if (element && element._owner && element._owner !== ReactCurrentOwner.current) { + childOwner = " It was passed a child from " + getComponentNameFromType(element._owner.type) + "."; + } + { + setCurrentlyValidatingElement$1(element); + error('Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.', currentComponentErrorInfo, childOwner); + setCurrentlyValidatingElement$1(null); + } + } + function validateChildKeys(node, parentType) { + if (typeof node !== "object") { + return; + } + if (isArray(node)) { + for (var i = 0; i < node.length; i++) { + var child = node[i]; + if (isValidElement2(child)) { + validateExplicitKey(child, parentType); + } + } + } else if (isValidElement2(node)) { + if (node._store) { + node._store.validated = true; + } + } else if (node) { + var iteratorFn = getIteratorFn(node); + if (typeof iteratorFn === "function") { + if (iteratorFn !== node.entries) { + var iterator = iteratorFn.call(node); + var step; + while (!(step = iterator.next()).done) { + if (isValidElement2(step.value)) { + validateExplicitKey(step.value, parentType); + } + } + } + } + } + } + function validatePropTypes(element) { + { + var type = element.type; + if (type === null || type === void 0 || typeof type === "string") { + return; + } + var propTypes; + if (typeof type === "function") { + propTypes = type.propTypes; + } else if (typeof type === "object" && (type.$$typeof === REACT_FORWARD_REF_TYPE || // Note: Memo only checks outer props here. + // Inner props are checked in the reconciler. + type.$$typeof === REACT_MEMO_TYPE)) { + propTypes = type.propTypes; + } else { + return; + } + if (propTypes) { + var name = getComponentNameFromType(type); + checkPropTypes(propTypes, element.props, "prop", name, element); + } else if (type.PropTypes !== void 0 && !propTypesMisspellWarningShown) { + propTypesMisspellWarningShown = true; + var _name = getComponentNameFromType(type); + error("Component %s declared `PropTypes` instead of `propTypes`. Did you misspell the property assignment?", _name || "Unknown"); + } + if (typeof type.getDefaultProps === "function" && !type.getDefaultProps.isReactClassApproved) { + error("getDefaultProps is only used on classic React.createClass definitions. Use a static property named `defaultProps` instead."); + } + } + } + function validateFragmentProps(fragment) { + { + var keys = Object.keys(fragment.props); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (key !== "children" && key !== "key") { + setCurrentlyValidatingElement$1(fragment); + error("Invalid prop `%s` supplied to `React.Fragment`. React.Fragment can only have `key` and `children` props.", key); + setCurrentlyValidatingElement$1(null); + break; + } + } + if (fragment.ref !== null) { + setCurrentlyValidatingElement$1(fragment); + error("Invalid attribute `ref` supplied to `React.Fragment`."); + setCurrentlyValidatingElement$1(null); + } + } + } + function createElementWithValidation(type, props, children) { + var validType = isValidElementType(type); + if (!validType) { + var info = ""; + if (type === void 0 || typeof type === "object" && type !== null && Object.keys(type).length === 0) { + info += " You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports."; + } + var sourceInfo = getSourceInfoErrorAddendumForProps(props); + if (sourceInfo) { + info += sourceInfo; + } else { + info += getDeclarationErrorAddendum(); + } + var typeString; + if (type === null) { + typeString = "null"; + } else if (isArray(type)) { + typeString = "array"; + } else if (type !== void 0 && type.$$typeof === REACT_ELEMENT_TYPE) { + typeString = "<" + (getComponentNameFromType(type.type) || "Unknown") + " />"; + info = " Did you accidentally export a JSX literal instead of a component?"; + } else { + typeString = typeof type; + } + { + error("React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s", typeString, info); + } + } + var element = createElement3.apply(this, arguments); + if (element == null) { + return element; + } + if (validType) { + for (var i = 2; i < arguments.length; i++) { + validateChildKeys(arguments[i], type); + } + } + if (type === REACT_FRAGMENT_TYPE) { + validateFragmentProps(element); + } else { + validatePropTypes(element); + } + return element; + } + var didWarnAboutDeprecatedCreateFactory = false; + function createFactoryWithValidation(type) { + var validatedFactory = createElementWithValidation.bind(null, type); + validatedFactory.type = type; + { + if (!didWarnAboutDeprecatedCreateFactory) { + didWarnAboutDeprecatedCreateFactory = true; + warn("React.createFactory() is deprecated and will be removed in a future major release. Consider using JSX or use React.createElement() directly instead."); + } + Object.defineProperty(validatedFactory, "type", { + enumerable: false, + get: function() { + warn("Factory.type is deprecated. Access the class directly before passing it to createFactory."); + Object.defineProperty(this, "type", { + value: type + }); + return type; + } + }); + } + return validatedFactory; + } + function cloneElementWithValidation(element, props, children) { + var newElement = cloneElement.apply(this, arguments); + for (var i = 2; i < arguments.length; i++) { + validateChildKeys(arguments[i], newElement.type); + } + validatePropTypes(newElement); + return newElement; + } + function startTransition(scope, options) { + var prevTransition = ReactCurrentBatchConfig.transition; + ReactCurrentBatchConfig.transition = {}; + var currentTransition = ReactCurrentBatchConfig.transition; + { + ReactCurrentBatchConfig.transition._updatedFibers = /* @__PURE__ */ new Set(); + } + try { + scope(); + } finally { + ReactCurrentBatchConfig.transition = prevTransition; + { + if (prevTransition === null && currentTransition._updatedFibers) { + var updatedFibersCount = currentTransition._updatedFibers.size; + if (updatedFibersCount > 10) { + warn("Detected a large number of updates inside startTransition. If this is due to a subscription please re-write it to use React provided hooks. Otherwise concurrent mode guarantees are off the table."); + } + currentTransition._updatedFibers.clear(); + } + } + } + } + var didWarnAboutMessageChannel = false; + var enqueueTaskImpl = null; + function enqueueTask(task) { + if (enqueueTaskImpl === null) { + try { + var requireString = ("require" + Math.random()).slice(0, 7); + var nodeRequire = module && module[requireString]; + enqueueTaskImpl = nodeRequire.call(module, "timers").setImmediate; + } catch (_err) { + enqueueTaskImpl = function(callback) { + { + if (didWarnAboutMessageChannel === false) { + didWarnAboutMessageChannel = true; + if (typeof MessageChannel === "undefined") { + error("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."); + } + } + } + var channel = new MessageChannel(); + channel.port1.onmessage = callback; + channel.port2.postMessage(void 0); + }; + } + } + return enqueueTaskImpl(task); + } + var actScopeDepth = 0; + var didWarnNoAwaitAct = false; + function act(callback) { + { + var prevActScopeDepth = actScopeDepth; + actScopeDepth++; + if (ReactCurrentActQueue.current === null) { + ReactCurrentActQueue.current = []; + } + var prevIsBatchingLegacy = ReactCurrentActQueue.isBatchingLegacy; + var result; + try { + ReactCurrentActQueue.isBatchingLegacy = true; + result = callback(); + if (!prevIsBatchingLegacy && ReactCurrentActQueue.didScheduleLegacyUpdate) { + var queue = ReactCurrentActQueue.current; + if (queue !== null) { + ReactCurrentActQueue.didScheduleLegacyUpdate = false; + flushActQueue(queue); + } + } + } catch (error2) { + popActScope(prevActScopeDepth); + throw error2; + } finally { + ReactCurrentActQueue.isBatchingLegacy = prevIsBatchingLegacy; + } + if (result !== null && typeof result === "object" && typeof result.then === "function") { + var thenableResult = result; + var wasAwaited = false; + var thenable = { + then: function(resolve, reject) { + wasAwaited = true; + thenableResult.then(function(returnValue2) { + popActScope(prevActScopeDepth); + if (actScopeDepth === 0) { + recursivelyFlushAsyncActWork(returnValue2, resolve, reject); + } else { + resolve(returnValue2); + } + }, function(error2) { + popActScope(prevActScopeDepth); + reject(error2); + }); + } + }; + { + if (!didWarnNoAwaitAct && typeof Promise !== "undefined") { + Promise.resolve().then(function() { + }).then(function() { + if (!wasAwaited) { + didWarnNoAwaitAct = true; + error("You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"); + } + }); + } + } + return thenable; + } else { + var returnValue = result; + popActScope(prevActScopeDepth); + if (actScopeDepth === 0) { + var _queue = ReactCurrentActQueue.current; + if (_queue !== null) { + flushActQueue(_queue); + ReactCurrentActQueue.current = null; + } + var _thenable = { + then: function(resolve, reject) { + if (ReactCurrentActQueue.current === null) { + ReactCurrentActQueue.current = []; + recursivelyFlushAsyncActWork(returnValue, resolve, reject); + } else { + resolve(returnValue); + } + } + }; + return _thenable; + } else { + var _thenable2 = { + then: function(resolve, reject) { + resolve(returnValue); + } + }; + return _thenable2; + } + } + } + } + function popActScope(prevActScopeDepth) { + { + if (prevActScopeDepth !== actScopeDepth - 1) { + error("You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one. "); + } + actScopeDepth = prevActScopeDepth; + } + } + function recursivelyFlushAsyncActWork(returnValue, resolve, reject) { + { + var queue = ReactCurrentActQueue.current; + if (queue !== null) { + try { + flushActQueue(queue); + enqueueTask(function() { + if (queue.length === 0) { + ReactCurrentActQueue.current = null; + resolve(returnValue); + } else { + recursivelyFlushAsyncActWork(returnValue, resolve, reject); + } + }); + } catch (error2) { + reject(error2); + } + } else { + resolve(returnValue); + } + } + } + var isFlushing = false; + function flushActQueue(queue) { + { + if (!isFlushing) { + isFlushing = true; + var i = 0; + try { + for (; i < queue.length; i++) { + var callback = queue[i]; + do { + callback = callback(true); + } while (callback !== null); + } + queue.length = 0; + } catch (error2) { + queue = queue.slice(i + 1); + throw error2; + } finally { + isFlushing = false; + } + } + } + } + var createElement$1 = createElementWithValidation; + var cloneElement$1 = cloneElementWithValidation; + var createFactory = createFactoryWithValidation; + var Children2 = { + map: mapChildren, + forEach: forEachChildren, + count: countChildren, + toArray, + only: onlyChild + }; + exports.Children = Children2; + exports.Component = Component2; + exports.Fragment = REACT_FRAGMENT_TYPE; + exports.Profiler = REACT_PROFILER_TYPE; + exports.PureComponent = PureComponent; + exports.StrictMode = REACT_STRICT_MODE_TYPE; + exports.Suspense = REACT_SUSPENSE_TYPE; + exports.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = ReactSharedInternals; + exports.act = act; + exports.cloneElement = cloneElement$1; + exports.createContext = createContext3; + exports.createElement = createElement$1; + exports.createFactory = createFactory; + exports.createRef = createRef; + exports.forwardRef = forwardRef2; + exports.isValidElement = isValidElement2; + exports.lazy = lazy; + exports.memo = memo2; + exports.startTransition = startTransition; + exports.unstable_act = act; + exports.useCallback = useCallback3; + exports.useContext = useContext3; + exports.useDebugValue = useDebugValue; + exports.useDeferredValue = useDeferredValue; + exports.useEffect = useEffect3; + exports.useId = useId; + exports.useImperativeHandle = useImperativeHandle; + exports.useInsertionEffect = useInsertionEffect; + exports.useLayoutEffect = useLayoutEffect3; + exports.useMemo = useMemo3; + exports.useReducer = useReducer; + exports.useRef = useRef3; + exports.useState = useState3; + exports.useSyncExternalStore = useSyncExternalStore; + exports.useTransition = useTransition; + exports.version = ReactVersion; + if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ !== "undefined" && typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop === "function") { + __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(new Error()); + } + })(); + } + } + }); + + // node_modules/react/index.js + var require_react = __commonJS({ + "node_modules/react/index.js"(exports, module) { + "use strict"; + if (false) { + module.exports = null; + } else { + module.exports = require_react_development(); + } + } + }); + + // node_modules/scheduler/cjs/scheduler.development.js + var require_scheduler_development = __commonJS({ + "node_modules/scheduler/cjs/scheduler.development.js"(exports) { + "use strict"; + if (true) { + (function() { + "use strict"; + if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ !== "undefined" && typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart === "function") { + __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error()); + } + var enableSchedulerDebugging = false; + var enableProfiling = false; + var frameYieldMs = 5; + function push(heap, node) { + var index = heap.length; + heap.push(node); + siftUp(heap, node, index); + } + function peek(heap) { + return heap.length === 0 ? null : heap[0]; + } + function pop(heap) { + if (heap.length === 0) { + return null; + } + var first = heap[0]; + var last = heap.pop(); + if (last !== first) { + heap[0] = last; + siftDown(heap, last, 0); + } + return first; + } + function siftUp(heap, node, i) { + var index = i; + while (index > 0) { + var parentIndex = index - 1 >>> 1; + var parent = heap[parentIndex]; + if (compare(parent, node) > 0) { + heap[parentIndex] = node; + heap[index] = parent; + index = parentIndex; + } else { + return; + } + } + } + function siftDown(heap, node, i) { + var index = i; + var length = heap.length; + var halfLength = length >>> 1; + while (index < halfLength) { + var leftIndex = (index + 1) * 2 - 1; + var left = heap[leftIndex]; + var rightIndex = leftIndex + 1; + var right = heap[rightIndex]; + if (compare(left, node) < 0) { + if (rightIndex < length && compare(right, left) < 0) { + heap[index] = right; + heap[rightIndex] = node; + index = rightIndex; + } else { + heap[index] = left; + heap[leftIndex] = node; + index = leftIndex; + } + } else if (rightIndex < length && compare(right, node) < 0) { + heap[index] = right; + heap[rightIndex] = node; + index = rightIndex; + } else { + return; + } + } + } + function compare(a, b) { + var diff = a.sortIndex - b.sortIndex; + return diff !== 0 ? diff : a.id - b.id; + } + var ImmediatePriority = 1; + var UserBlockingPriority = 2; + var NormalPriority = 3; + var LowPriority = 4; + var IdlePriority = 5; + function markTaskErrored(task, ms) { + } + var hasPerformanceNow = typeof performance === "object" && typeof performance.now === "function"; + if (hasPerformanceNow) { + var localPerformance = performance; + exports.unstable_now = function() { + return localPerformance.now(); + }; + } else { + var localDate = Date; + var initialTime = localDate.now(); + exports.unstable_now = function() { + return localDate.now() - initialTime; + }; + } + var maxSigned31BitInt = 1073741823; + var IMMEDIATE_PRIORITY_TIMEOUT = -1; + var USER_BLOCKING_PRIORITY_TIMEOUT = 250; + var NORMAL_PRIORITY_TIMEOUT = 5e3; + var LOW_PRIORITY_TIMEOUT = 1e4; + var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; + var taskQueue = []; + var timerQueue = []; + var taskIdCounter = 1; + var currentTask = null; + var currentPriorityLevel = NormalPriority; + var isPerformingWork = false; + var isHostCallbackScheduled = false; + var isHostTimeoutScheduled = false; + var localSetTimeout = typeof setTimeout === "function" ? setTimeout : null; + var localClearTimeout = typeof clearTimeout === "function" ? clearTimeout : null; + var localSetImmediate = typeof setImmediate !== "undefined" ? setImmediate : null; + var isInputPending = typeof navigator !== "undefined" && navigator.scheduling !== void 0 && navigator.scheduling.isInputPending !== void 0 ? navigator.scheduling.isInputPending.bind(navigator.scheduling) : null; + function advanceTimers(currentTime) { + var timer = peek(timerQueue); + while (timer !== null) { + if (timer.callback === null) { + pop(timerQueue); + } else if (timer.startTime <= currentTime) { + pop(timerQueue); + timer.sortIndex = timer.expirationTime; + push(taskQueue, timer); + } else { + return; + } + timer = peek(timerQueue); + } + } + function handleTimeout(currentTime) { + isHostTimeoutScheduled = false; + advanceTimers(currentTime); + if (!isHostCallbackScheduled) { + if (peek(taskQueue) !== null) { + isHostCallbackScheduled = true; + requestHostCallback(flushWork); + } else { + var firstTimer = peek(timerQueue); + if (firstTimer !== null) { + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); + } + } + } + } + function flushWork(hasTimeRemaining, initialTime2) { + isHostCallbackScheduled = false; + if (isHostTimeoutScheduled) { + isHostTimeoutScheduled = false; + cancelHostTimeout(); + } + isPerformingWork = true; + var previousPriorityLevel = currentPriorityLevel; + try { + if (enableProfiling) { + try { + return workLoop(hasTimeRemaining, initialTime2); + } catch (error) { + if (currentTask !== null) { + var currentTime = exports.unstable_now(); + markTaskErrored(currentTask, currentTime); + currentTask.isQueued = false; + } + throw error; + } + } else { + return workLoop(hasTimeRemaining, initialTime2); + } + } finally { + currentTask = null; + currentPriorityLevel = previousPriorityLevel; + isPerformingWork = false; + } + } + function workLoop(hasTimeRemaining, initialTime2) { + var currentTime = initialTime2; + advanceTimers(currentTime); + currentTask = peek(taskQueue); + while (currentTask !== null && !enableSchedulerDebugging) { + if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost())) { + break; + } + var callback = currentTask.callback; + if (typeof callback === "function") { + currentTask.callback = null; + currentPriorityLevel = currentTask.priorityLevel; + var didUserCallbackTimeout = currentTask.expirationTime <= currentTime; + var continuationCallback = callback(didUserCallbackTimeout); + currentTime = exports.unstable_now(); + if (typeof continuationCallback === "function") { + currentTask.callback = continuationCallback; + } else { + if (currentTask === peek(taskQueue)) { + pop(taskQueue); + } + } + advanceTimers(currentTime); + } else { + pop(taskQueue); + } + currentTask = peek(taskQueue); + } + if (currentTask !== null) { + return true; + } else { + var firstTimer = peek(timerQueue); + if (firstTimer !== null) { + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); + } + return false; + } + } + function unstable_runWithPriority(priorityLevel, eventHandler) { + switch (priorityLevel) { + case ImmediatePriority: + case UserBlockingPriority: + case NormalPriority: + case LowPriority: + case IdlePriority: + break; + default: + priorityLevel = NormalPriority; + } + var previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = priorityLevel; + try { + return eventHandler(); + } finally { + currentPriorityLevel = previousPriorityLevel; + } + } + function unstable_next(eventHandler) { + var priorityLevel; + switch (currentPriorityLevel) { + case ImmediatePriority: + case UserBlockingPriority: + case NormalPriority: + priorityLevel = NormalPriority; + break; + default: + priorityLevel = currentPriorityLevel; + break; + } + var previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = priorityLevel; + try { + return eventHandler(); + } finally { + currentPriorityLevel = previousPriorityLevel; + } + } + function unstable_wrapCallback(callback) { + var parentPriorityLevel = currentPriorityLevel; + return function() { + var previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = parentPriorityLevel; + try { + return callback.apply(this, arguments); + } finally { + currentPriorityLevel = previousPriorityLevel; + } + }; + } + function unstable_scheduleCallback(priorityLevel, callback, options) { + var currentTime = exports.unstable_now(); + var startTime2; + if (typeof options === "object" && options !== null) { + var delay = options.delay; + if (typeof delay === "number" && delay > 0) { + startTime2 = currentTime + delay; + } else { + startTime2 = currentTime; + } + } else { + startTime2 = currentTime; + } + var timeout; + switch (priorityLevel) { + case ImmediatePriority: + timeout = IMMEDIATE_PRIORITY_TIMEOUT; + break; + case UserBlockingPriority: + timeout = USER_BLOCKING_PRIORITY_TIMEOUT; + break; + case IdlePriority: + timeout = IDLE_PRIORITY_TIMEOUT; + break; + case LowPriority: + timeout = LOW_PRIORITY_TIMEOUT; + break; + case NormalPriority: + default: + timeout = NORMAL_PRIORITY_TIMEOUT; + break; + } + var expirationTime = startTime2 + timeout; + var newTask = { + id: taskIdCounter++, + callback, + priorityLevel, + startTime: startTime2, + expirationTime, + sortIndex: -1 + }; + if (startTime2 > currentTime) { + newTask.sortIndex = startTime2; + push(timerQueue, newTask); + if (peek(taskQueue) === null && newTask === peek(timerQueue)) { + if (isHostTimeoutScheduled) { + cancelHostTimeout(); + } else { + isHostTimeoutScheduled = true; + } + requestHostTimeout(handleTimeout, startTime2 - currentTime); + } + } else { + newTask.sortIndex = expirationTime; + push(taskQueue, newTask); + if (!isHostCallbackScheduled && !isPerformingWork) { + isHostCallbackScheduled = true; + requestHostCallback(flushWork); + } + } + return newTask; + } + function unstable_pauseExecution() { + } + function unstable_continueExecution() { + if (!isHostCallbackScheduled && !isPerformingWork) { + isHostCallbackScheduled = true; + requestHostCallback(flushWork); + } + } + function unstable_getFirstCallbackNode() { + return peek(taskQueue); + } + function unstable_cancelCallback(task) { + task.callback = null; + } + function unstable_getCurrentPriorityLevel() { + return currentPriorityLevel; + } + var isMessageLoopRunning = false; + var scheduledHostCallback = null; + var taskTimeoutID = -1; + var frameInterval = frameYieldMs; + var startTime = -1; + function shouldYieldToHost() { + var timeElapsed = exports.unstable_now() - startTime; + if (timeElapsed < frameInterval) { + return false; + } + return true; + } + function requestPaint() { + } + function forceFrameRate(fps) { + if (fps < 0 || fps > 125) { + console["error"]("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"); + return; + } + if (fps > 0) { + frameInterval = Math.floor(1e3 / fps); + } else { + frameInterval = frameYieldMs; + } + } + var performWorkUntilDeadline = function() { + if (scheduledHostCallback !== null) { + var currentTime = exports.unstable_now(); + startTime = currentTime; + var hasTimeRemaining = true; + var hasMoreWork = true; + try { + hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime); + } finally { + if (hasMoreWork) { + schedulePerformWorkUntilDeadline(); + } else { + isMessageLoopRunning = false; + scheduledHostCallback = null; + } + } + } else { + isMessageLoopRunning = false; + } + }; + var schedulePerformWorkUntilDeadline; + if (typeof localSetImmediate === "function") { + schedulePerformWorkUntilDeadline = function() { + localSetImmediate(performWorkUntilDeadline); + }; + } else if (typeof MessageChannel !== "undefined") { + var channel = new MessageChannel(); + var port = channel.port2; + channel.port1.onmessage = performWorkUntilDeadline; + schedulePerformWorkUntilDeadline = function() { + port.postMessage(null); + }; + } else { + schedulePerformWorkUntilDeadline = function() { + localSetTimeout(performWorkUntilDeadline, 0); + }; + } + function requestHostCallback(callback) { + scheduledHostCallback = callback; + if (!isMessageLoopRunning) { + isMessageLoopRunning = true; + schedulePerformWorkUntilDeadline(); + } + } + function requestHostTimeout(callback, ms) { + taskTimeoutID = localSetTimeout(function() { + callback(exports.unstable_now()); + }, ms); + } + function cancelHostTimeout() { + localClearTimeout(taskTimeoutID); + taskTimeoutID = -1; + } + var unstable_requestPaint = requestPaint; + var unstable_Profiling = null; + exports.unstable_IdlePriority = IdlePriority; + exports.unstable_ImmediatePriority = ImmediatePriority; + exports.unstable_LowPriority = LowPriority; + exports.unstable_NormalPriority = NormalPriority; + exports.unstable_Profiling = unstable_Profiling; + exports.unstable_UserBlockingPriority = UserBlockingPriority; + exports.unstable_cancelCallback = unstable_cancelCallback; + exports.unstable_continueExecution = unstable_continueExecution; + exports.unstable_forceFrameRate = forceFrameRate; + exports.unstable_getCurrentPriorityLevel = unstable_getCurrentPriorityLevel; + exports.unstable_getFirstCallbackNode = unstable_getFirstCallbackNode; + exports.unstable_next = unstable_next; + exports.unstable_pauseExecution = unstable_pauseExecution; + exports.unstable_requestPaint = unstable_requestPaint; + exports.unstable_runWithPriority = unstable_runWithPriority; + exports.unstable_scheduleCallback = unstable_scheduleCallback; + exports.unstable_shouldYield = shouldYieldToHost; + exports.unstable_wrapCallback = unstable_wrapCallback; + if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ !== "undefined" && typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop === "function") { + __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(new Error()); + } + })(); + } + } + }); + + // node_modules/scheduler/index.js + var require_scheduler = __commonJS({ + "node_modules/scheduler/index.js"(exports, module) { + "use strict"; + if (false) { + module.exports = null; + } else { + module.exports = require_scheduler_development(); + } + } + }); + + // node_modules/react-dom/cjs/react-dom.development.js + var require_react_dom_development = __commonJS({ + "node_modules/react-dom/cjs/react-dom.development.js"(exports) { + "use strict"; + if (true) { + (function() { + "use strict"; + if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ !== "undefined" && typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart === "function") { + __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error()); + } + var React4 = require_react(); + var Scheduler = require_scheduler(); + var ReactSharedInternals = React4.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED; + var suppressWarning = false; + function setSuppressWarning(newSuppressWarning) { + { + suppressWarning = newSuppressWarning; + } + } + function warn(format) { + { + if (!suppressWarning) { + for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + printWarning("warn", format, args); + } + } + } + function error(format) { + { + if (!suppressWarning) { + for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + args[_key2 - 1] = arguments[_key2]; + } + printWarning("error", format, args); + } + } + } + function printWarning(level, format, args) { + { + var ReactDebugCurrentFrame2 = ReactSharedInternals.ReactDebugCurrentFrame; + var stack = ReactDebugCurrentFrame2.getStackAddendum(); + if (stack !== "") { + format += "%s"; + args = args.concat([stack]); + } + var argsWithFormat = args.map(function(item) { + return String(item); + }); + argsWithFormat.unshift("Warning: " + format); + Function.prototype.apply.call(console[level], console, argsWithFormat); + } + } + var FunctionComponent = 0; + var ClassComponent = 1; + var IndeterminateComponent = 2; + var HostRoot = 3; + var HostPortal = 4; + var HostComponent = 5; + var HostText = 6; + var Fragment3 = 7; + var Mode = 8; + var ContextConsumer = 9; + var ContextProvider = 10; + var ForwardRef = 11; + var Profiler = 12; + var SuspenseComponent = 13; + var MemoComponent = 14; + var SimpleMemoComponent = 15; + var LazyComponent = 16; + var IncompleteClassComponent = 17; + var DehydratedFragment = 18; + var SuspenseListComponent = 19; + var ScopeComponent = 21; + var OffscreenComponent = 22; + var LegacyHiddenComponent = 23; + var CacheComponent = 24; + var TracingMarkerComponent = 25; + var enableClientRenderFallbackOnTextMismatch = true; + var enableNewReconciler = false; + var enableLazyContextPropagation = false; + var enableLegacyHidden = false; + var enableSuspenseAvoidThisFallback = false; + var disableCommentsAsDOMContainers = true; + var enableCustomElementPropertySupport = false; + var warnAboutStringRefs = true; + var enableSchedulingProfiler = true; + var enableProfilerTimer = true; + var enableProfilerCommitHooks = true; + var allNativeEvents = /* @__PURE__ */ new Set(); + var registrationNameDependencies = {}; + var possibleRegistrationNames = {}; + function registerTwoPhaseEvent(registrationName, dependencies) { + registerDirectEvent(registrationName, dependencies); + registerDirectEvent(registrationName + "Capture", dependencies); + } + function registerDirectEvent(registrationName, dependencies) { + { + if (registrationNameDependencies[registrationName]) { + error("EventRegistry: More than one plugin attempted to publish the same registration name, `%s`.", registrationName); + } + } + registrationNameDependencies[registrationName] = dependencies; + { + var lowerCasedName = registrationName.toLowerCase(); + possibleRegistrationNames[lowerCasedName] = registrationName; + if (registrationName === "onDoubleClick") { + possibleRegistrationNames.ondblclick = registrationName; + } + } + for (var i = 0; i < dependencies.length; i++) { + allNativeEvents.add(dependencies[i]); + } + } + var canUseDOM = !!(typeof window !== "undefined" && typeof window.document !== "undefined" && typeof window.document.createElement !== "undefined"); + var hasOwnProperty = Object.prototype.hasOwnProperty; + function typeName(value) { + { + var hasToStringTag = typeof Symbol === "function" && Symbol.toStringTag; + var type = hasToStringTag && value[Symbol.toStringTag] || value.constructor.name || "Object"; + return type; + } + } + function willCoercionThrow(value) { + { + try { + testStringCoercion(value); + return false; + } catch (e) { + return true; + } + } + } + function testStringCoercion(value) { + return "" + value; + } + function checkAttributeStringCoercion(value, attributeName) { + { + if (willCoercionThrow(value)) { + error("The provided `%s` attribute is an unsupported type %s. This value must be coerced to a string before before using it here.", attributeName, typeName(value)); + return testStringCoercion(value); + } + } + } + function checkKeyStringCoercion(value) { + { + if (willCoercionThrow(value)) { + error("The provided key is an unsupported type %s. This value must be coerced to a string before before using it here.", typeName(value)); + return testStringCoercion(value); + } + } + } + function checkPropStringCoercion(value, propName) { + { + if (willCoercionThrow(value)) { + error("The provided `%s` prop is an unsupported type %s. This value must be coerced to a string before before using it here.", propName, typeName(value)); + return testStringCoercion(value); + } + } + } + function checkCSSPropertyStringCoercion(value, propName) { + { + if (willCoercionThrow(value)) { + error("The provided `%s` CSS property is an unsupported type %s. This value must be coerced to a string before before using it here.", propName, typeName(value)); + return testStringCoercion(value); + } + } + } + function checkHtmlStringCoercion(value) { + { + if (willCoercionThrow(value)) { + error("The provided HTML markup uses a value of unsupported type %s. This value must be coerced to a string before before using it here.", typeName(value)); + return testStringCoercion(value); + } + } + } + function checkFormFieldValueStringCoercion(value) { + { + if (willCoercionThrow(value)) { + error("Form field values (value, checked, defaultValue, or defaultChecked props) must be strings, not %s. This value must be coerced to a string before before using it here.", typeName(value)); + return testStringCoercion(value); + } + } + } + var RESERVED = 0; + var STRING = 1; + var BOOLEANISH_STRING = 2; + var BOOLEAN = 3; + var OVERLOADED_BOOLEAN = 4; + var NUMERIC = 5; + var POSITIVE_NUMERIC = 6; + var ATTRIBUTE_NAME_START_CHAR = ":A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD"; + var ATTRIBUTE_NAME_CHAR = ATTRIBUTE_NAME_START_CHAR + "\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040"; + var VALID_ATTRIBUTE_NAME_REGEX = new RegExp("^[" + ATTRIBUTE_NAME_START_CHAR + "][" + ATTRIBUTE_NAME_CHAR + "]*$"); + var illegalAttributeNameCache = {}; + var validatedAttributeNameCache = {}; + function isAttributeNameSafe(attributeName) { + if (hasOwnProperty.call(validatedAttributeNameCache, attributeName)) { + return true; + } + if (hasOwnProperty.call(illegalAttributeNameCache, attributeName)) { + return false; + } + if (VALID_ATTRIBUTE_NAME_REGEX.test(attributeName)) { + validatedAttributeNameCache[attributeName] = true; + return true; + } + illegalAttributeNameCache[attributeName] = true; + { + error("Invalid attribute name: `%s`", attributeName); + } + return false; + } + function shouldIgnoreAttribute(name, propertyInfo, isCustomComponentTag) { + if (propertyInfo !== null) { + return propertyInfo.type === RESERVED; + } + if (isCustomComponentTag) { + return false; + } + if (name.length > 2 && (name[0] === "o" || name[0] === "O") && (name[1] === "n" || name[1] === "N")) { + return true; + } + return false; + } + function shouldRemoveAttributeWithWarning(name, value, propertyInfo, isCustomComponentTag) { + if (propertyInfo !== null && propertyInfo.type === RESERVED) { + return false; + } + switch (typeof value) { + case "function": + case "symbol": + return true; + case "boolean": { + if (isCustomComponentTag) { + return false; + } + if (propertyInfo !== null) { + return !propertyInfo.acceptsBooleans; + } else { + var prefix2 = name.toLowerCase().slice(0, 5); + return prefix2 !== "data-" && prefix2 !== "aria-"; + } + } + default: + return false; + } + } + function shouldRemoveAttribute(name, value, propertyInfo, isCustomComponentTag) { + if (value === null || typeof value === "undefined") { + return true; + } + if (shouldRemoveAttributeWithWarning(name, value, propertyInfo, isCustomComponentTag)) { + return true; + } + if (isCustomComponentTag) { + return false; + } + if (propertyInfo !== null) { + switch (propertyInfo.type) { + case BOOLEAN: + return !value; + case OVERLOADED_BOOLEAN: + return value === false; + case NUMERIC: + return isNaN(value); + case POSITIVE_NUMERIC: + return isNaN(value) || value < 1; + } + } + return false; + } + function getPropertyInfo(name) { + return properties.hasOwnProperty(name) ? properties[name] : null; + } + function PropertyInfoRecord(name, type, mustUseProperty, attributeName, attributeNamespace, sanitizeURL2, removeEmptyString) { + this.acceptsBooleans = type === BOOLEANISH_STRING || type === BOOLEAN || type === OVERLOADED_BOOLEAN; + this.attributeName = attributeName; + this.attributeNamespace = attributeNamespace; + this.mustUseProperty = mustUseProperty; + this.propertyName = name; + this.type = type; + this.sanitizeURL = sanitizeURL2; + this.removeEmptyString = removeEmptyString; + } + var properties = {}; + var reservedProps = [ + "children", + "dangerouslySetInnerHTML", + // TODO: This prevents the assignment of defaultValue to regular + // elements (not just inputs). Now that ReactDOMInput assigns to the + // defaultValue property -- do we need this? + "defaultValue", + "defaultChecked", + "innerHTML", + "suppressContentEditableWarning", + "suppressHydrationWarning", + "style" + ]; + reservedProps.forEach(function(name) { + properties[name] = new PropertyInfoRecord( + name, + RESERVED, + false, + // mustUseProperty + name, + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + [["acceptCharset", "accept-charset"], ["className", "class"], ["htmlFor", "for"], ["httpEquiv", "http-equiv"]].forEach(function(_ref) { + var name = _ref[0], attributeName = _ref[1]; + properties[name] = new PropertyInfoRecord( + name, + STRING, + false, + // mustUseProperty + attributeName, + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + ["contentEditable", "draggable", "spellCheck", "value"].forEach(function(name) { + properties[name] = new PropertyInfoRecord( + name, + BOOLEANISH_STRING, + false, + // mustUseProperty + name.toLowerCase(), + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + ["autoReverse", "externalResourcesRequired", "focusable", "preserveAlpha"].forEach(function(name) { + properties[name] = new PropertyInfoRecord( + name, + BOOLEANISH_STRING, + false, + // mustUseProperty + name, + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + [ + "allowFullScreen", + "async", + // Note: there is a special case that prevents it from being written to the DOM + // on the client side because the browsers are inconsistent. Instead we call focus(). + "autoFocus", + "autoPlay", + "controls", + "default", + "defer", + "disabled", + "disablePictureInPicture", + "disableRemotePlayback", + "formNoValidate", + "hidden", + "loop", + "noModule", + "noValidate", + "open", + "playsInline", + "readOnly", + "required", + "reversed", + "scoped", + "seamless", + // Microdata + "itemScope" + ].forEach(function(name) { + properties[name] = new PropertyInfoRecord( + name, + BOOLEAN, + false, + // mustUseProperty + name.toLowerCase(), + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + [ + "checked", + // Note: `option.selected` is not updated if `select.multiple` is + // disabled with `removeAttribute`. We have special logic for handling this. + "multiple", + "muted", + "selected" + // NOTE: if you add a camelCased prop to this list, + // you'll need to set attributeName to name.toLowerCase() + // instead in the assignment below. + ].forEach(function(name) { + properties[name] = new PropertyInfoRecord( + name, + BOOLEAN, + true, + // mustUseProperty + name, + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + [ + "capture", + "download" + // NOTE: if you add a camelCased prop to this list, + // you'll need to set attributeName to name.toLowerCase() + // instead in the assignment below. + ].forEach(function(name) { + properties[name] = new PropertyInfoRecord( + name, + OVERLOADED_BOOLEAN, + false, + // mustUseProperty + name, + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + [ + "cols", + "rows", + "size", + "span" + // NOTE: if you add a camelCased prop to this list, + // you'll need to set attributeName to name.toLowerCase() + // instead in the assignment below. + ].forEach(function(name) { + properties[name] = new PropertyInfoRecord( + name, + POSITIVE_NUMERIC, + false, + // mustUseProperty + name, + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + ["rowSpan", "start"].forEach(function(name) { + properties[name] = new PropertyInfoRecord( + name, + NUMERIC, + false, + // mustUseProperty + name.toLowerCase(), + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + var CAMELIZE = /[\-\:]([a-z])/g; + var capitalize = function(token) { + return token[1].toUpperCase(); + }; + [ + "accent-height", + "alignment-baseline", + "arabic-form", + "baseline-shift", + "cap-height", + "clip-path", + "clip-rule", + "color-interpolation", + "color-interpolation-filters", + "color-profile", + "color-rendering", + "dominant-baseline", + "enable-background", + "fill-opacity", + "fill-rule", + "flood-color", + "flood-opacity", + "font-family", + "font-size", + "font-size-adjust", + "font-stretch", + "font-style", + "font-variant", + "font-weight", + "glyph-name", + "glyph-orientation-horizontal", + "glyph-orientation-vertical", + "horiz-adv-x", + "horiz-origin-x", + "image-rendering", + "letter-spacing", + "lighting-color", + "marker-end", + "marker-mid", + "marker-start", + "overline-position", + "overline-thickness", + "paint-order", + "panose-1", + "pointer-events", + "rendering-intent", + "shape-rendering", + "stop-color", + "stop-opacity", + "strikethrough-position", + "strikethrough-thickness", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width", + "text-anchor", + "text-decoration", + "text-rendering", + "underline-position", + "underline-thickness", + "unicode-bidi", + "unicode-range", + "units-per-em", + "v-alphabetic", + "v-hanging", + "v-ideographic", + "v-mathematical", + "vector-effect", + "vert-adv-y", + "vert-origin-x", + "vert-origin-y", + "word-spacing", + "writing-mode", + "xmlns:xlink", + "x-height" + // NOTE: if you add a camelCased prop to this list, + // you'll need to set attributeName to name.toLowerCase() + // instead in the assignment below. + ].forEach(function(attributeName) { + var name = attributeName.replace(CAMELIZE, capitalize); + properties[name] = new PropertyInfoRecord( + name, + STRING, + false, + // mustUseProperty + attributeName, + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + [ + "xlink:actuate", + "xlink:arcrole", + "xlink:role", + "xlink:show", + "xlink:title", + "xlink:type" + // NOTE: if you add a camelCased prop to this list, + // you'll need to set attributeName to name.toLowerCase() + // instead in the assignment below. + ].forEach(function(attributeName) { + var name = attributeName.replace(CAMELIZE, capitalize); + properties[name] = new PropertyInfoRecord( + name, + STRING, + false, + // mustUseProperty + attributeName, + "http://www.w3.org/1999/xlink", + false, + // sanitizeURL + false + ); + }); + [ + "xml:base", + "xml:lang", + "xml:space" + // NOTE: if you add a camelCased prop to this list, + // you'll need to set attributeName to name.toLowerCase() + // instead in the assignment below. + ].forEach(function(attributeName) { + var name = attributeName.replace(CAMELIZE, capitalize); + properties[name] = new PropertyInfoRecord( + name, + STRING, + false, + // mustUseProperty + attributeName, + "http://www.w3.org/XML/1998/namespace", + false, + // sanitizeURL + false + ); + }); + ["tabIndex", "crossOrigin"].forEach(function(attributeName) { + properties[attributeName] = new PropertyInfoRecord( + attributeName, + STRING, + false, + // mustUseProperty + attributeName.toLowerCase(), + // attributeName + null, + // attributeNamespace + false, + // sanitizeURL + false + ); + }); + var xlinkHref = "xlinkHref"; + properties[xlinkHref] = new PropertyInfoRecord( + "xlinkHref", + STRING, + false, + // mustUseProperty + "xlink:href", + "http://www.w3.org/1999/xlink", + true, + // sanitizeURL + false + ); + ["src", "href", "action", "formAction"].forEach(function(attributeName) { + properties[attributeName] = new PropertyInfoRecord( + attributeName, + STRING, + false, + // mustUseProperty + attributeName.toLowerCase(), + // attributeName + null, + // attributeNamespace + true, + // sanitizeURL + true + ); + }); + var isJavaScriptProtocol = /^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*\:/i; + var didWarn = false; + function sanitizeURL(url) { + { + if (!didWarn && isJavaScriptProtocol.test(url)) { + didWarn = true; + error("A future version of React will block javascript: URLs as a security precaution. Use event handlers instead if you can. If you need to generate unsafe HTML try using dangerouslySetInnerHTML instead. React was passed %s.", JSON.stringify(url)); + } + } + } + function getValueForProperty(node, name, expected, propertyInfo) { + { + if (propertyInfo.mustUseProperty) { + var propertyName = propertyInfo.propertyName; + return node[propertyName]; + } else { + { + checkAttributeStringCoercion(expected, name); + } + if (propertyInfo.sanitizeURL) { + sanitizeURL("" + expected); + } + var attributeName = propertyInfo.attributeName; + var stringValue = null; + if (propertyInfo.type === OVERLOADED_BOOLEAN) { + if (node.hasAttribute(attributeName)) { + var value = node.getAttribute(attributeName); + if (value === "") { + return true; + } + if (shouldRemoveAttribute(name, expected, propertyInfo, false)) { + return value; + } + if (value === "" + expected) { + return expected; + } + return value; + } + } else if (node.hasAttribute(attributeName)) { + if (shouldRemoveAttribute(name, expected, propertyInfo, false)) { + return node.getAttribute(attributeName); + } + if (propertyInfo.type === BOOLEAN) { + return expected; + } + stringValue = node.getAttribute(attributeName); + } + if (shouldRemoveAttribute(name, expected, propertyInfo, false)) { + return stringValue === null ? expected : stringValue; + } else if (stringValue === "" + expected) { + return expected; + } else { + return stringValue; + } + } + } + } + function getValueForAttribute(node, name, expected, isCustomComponentTag) { + { + if (!isAttributeNameSafe(name)) { + return; + } + if (!node.hasAttribute(name)) { + return expected === void 0 ? void 0 : null; + } + var value = node.getAttribute(name); + { + checkAttributeStringCoercion(expected, name); + } + if (value === "" + expected) { + return expected; + } + return value; + } + } + function setValueForProperty(node, name, value, isCustomComponentTag) { + var propertyInfo = getPropertyInfo(name); + if (shouldIgnoreAttribute(name, propertyInfo, isCustomComponentTag)) { + return; + } + if (shouldRemoveAttribute(name, value, propertyInfo, isCustomComponentTag)) { + value = null; + } + if (isCustomComponentTag || propertyInfo === null) { + if (isAttributeNameSafe(name)) { + var _attributeName = name; + if (value === null) { + node.removeAttribute(_attributeName); + } else { + { + checkAttributeStringCoercion(value, name); + } + node.setAttribute(_attributeName, "" + value); + } + } + return; + } + var mustUseProperty = propertyInfo.mustUseProperty; + if (mustUseProperty) { + var propertyName = propertyInfo.propertyName; + if (value === null) { + var type = propertyInfo.type; + node[propertyName] = type === BOOLEAN ? false : ""; + } else { + node[propertyName] = value; + } + return; + } + var attributeName = propertyInfo.attributeName, attributeNamespace = propertyInfo.attributeNamespace; + if (value === null) { + node.removeAttribute(attributeName); + } else { + var _type = propertyInfo.type; + var attributeValue; + if (_type === BOOLEAN || _type === OVERLOADED_BOOLEAN && value === true) { + attributeValue = ""; + } else { + { + { + checkAttributeStringCoercion(value, attributeName); + } + attributeValue = "" + value; + } + if (propertyInfo.sanitizeURL) { + sanitizeURL(attributeValue.toString()); + } + } + if (attributeNamespace) { + node.setAttributeNS(attributeNamespace, attributeName, attributeValue); + } else { + node.setAttribute(attributeName, attributeValue); + } + } + } + var REACT_ELEMENT_TYPE = Symbol.for("react.element"); + var REACT_PORTAL_TYPE = Symbol.for("react.portal"); + var REACT_FRAGMENT_TYPE = Symbol.for("react.fragment"); + var REACT_STRICT_MODE_TYPE = Symbol.for("react.strict_mode"); + var REACT_PROFILER_TYPE = Symbol.for("react.profiler"); + var REACT_PROVIDER_TYPE = Symbol.for("react.provider"); + var REACT_CONTEXT_TYPE = Symbol.for("react.context"); + var REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref"); + var REACT_SUSPENSE_TYPE = Symbol.for("react.suspense"); + var REACT_SUSPENSE_LIST_TYPE = Symbol.for("react.suspense_list"); + var REACT_MEMO_TYPE = Symbol.for("react.memo"); + var REACT_LAZY_TYPE = Symbol.for("react.lazy"); + var REACT_SCOPE_TYPE = Symbol.for("react.scope"); + var REACT_DEBUG_TRACING_MODE_TYPE = Symbol.for("react.debug_trace_mode"); + var REACT_OFFSCREEN_TYPE = Symbol.for("react.offscreen"); + var REACT_LEGACY_HIDDEN_TYPE = Symbol.for("react.legacy_hidden"); + var REACT_CACHE_TYPE = Symbol.for("react.cache"); + var REACT_TRACING_MARKER_TYPE = Symbol.for("react.tracing_marker"); + var MAYBE_ITERATOR_SYMBOL = Symbol.iterator; + var FAUX_ITERATOR_SYMBOL = "@@iterator"; + function getIteratorFn(maybeIterable) { + if (maybeIterable === null || typeof maybeIterable !== "object") { + return null; + } + var maybeIterator = MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL] || maybeIterable[FAUX_ITERATOR_SYMBOL]; + if (typeof maybeIterator === "function") { + return maybeIterator; + } + return null; + } + var assign = Object.assign; + var disabledDepth = 0; + var prevLog; + var prevInfo; + var prevWarn; + var prevError; + var prevGroup; + var prevGroupCollapsed; + var prevGroupEnd; + function disabledLog() { + } + disabledLog.__reactDisabledLog = true; + function disableLogs() { + { + if (disabledDepth === 0) { + prevLog = console.log; + prevInfo = console.info; + prevWarn = console.warn; + prevError = console.error; + prevGroup = console.group; + prevGroupCollapsed = console.groupCollapsed; + prevGroupEnd = console.groupEnd; + var props = { + configurable: true, + enumerable: true, + value: disabledLog, + writable: true + }; + Object.defineProperties(console, { + info: props, + log: props, + warn: props, + error: props, + group: props, + groupCollapsed: props, + groupEnd: props + }); + } + disabledDepth++; + } + } + function reenableLogs() { + { + disabledDepth--; + if (disabledDepth === 0) { + var props = { + configurable: true, + enumerable: true, + writable: true + }; + Object.defineProperties(console, { + log: assign({}, props, { + value: prevLog + }), + info: assign({}, props, { + value: prevInfo + }), + warn: assign({}, props, { + value: prevWarn + }), + error: assign({}, props, { + value: prevError + }), + group: assign({}, props, { + value: prevGroup + }), + groupCollapsed: assign({}, props, { + value: prevGroupCollapsed + }), + groupEnd: assign({}, props, { + value: prevGroupEnd + }) + }); + } + if (disabledDepth < 0) { + error("disabledDepth fell below zero. This is a bug in React. Please file an issue."); + } + } + } + var ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher; + var prefix; + function describeBuiltInComponentFrame(name, source, ownerFn) { + { + if (prefix === void 0) { + try { + throw Error(); + } catch (x) { + var match = x.stack.trim().match(/\n( *(at )?)/); + prefix = match && match[1] || ""; + } + } + return "\n" + prefix + name; + } + } + var reentry = false; + var componentFrameCache; + { + var PossiblyWeakMap = typeof WeakMap === "function" ? WeakMap : Map; + componentFrameCache = new PossiblyWeakMap(); + } + function describeNativeComponentFrame(fn, construct) { + if (!fn || reentry) { + return ""; + } + { + var frame = componentFrameCache.get(fn); + if (frame !== void 0) { + return frame; + } + } + var control; + reentry = true; + var previousPrepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = void 0; + var previousDispatcher; + { + previousDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = null; + disableLogs(); + } + try { + if (construct) { + var Fake = function() { + throw Error(); + }; + Object.defineProperty(Fake.prototype, "props", { + set: function() { + throw Error(); + } + }); + if (typeof Reflect === "object" && Reflect.construct) { + try { + Reflect.construct(Fake, []); + } catch (x) { + control = x; + } + Reflect.construct(fn, [], Fake); + } else { + try { + Fake.call(); + } catch (x) { + control = x; + } + fn.call(Fake.prototype); + } + } else { + try { + throw Error(); + } catch (x) { + control = x; + } + fn(); + } + } catch (sample) { + if (sample && control && typeof sample.stack === "string") { + var sampleLines = sample.stack.split("\n"); + var controlLines = control.stack.split("\n"); + var s = sampleLines.length - 1; + var c = controlLines.length - 1; + while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) { + c--; + } + for (; s >= 1 && c >= 0; s--, c--) { + if (sampleLines[s] !== controlLines[c]) { + if (s !== 1 || c !== 1) { + do { + s--; + c--; + if (c < 0 || sampleLines[s] !== controlLines[c]) { + var _frame = "\n" + sampleLines[s].replace(" at new ", " at "); + if (fn.displayName && _frame.includes("")) { + _frame = _frame.replace("", fn.displayName); + } + { + if (typeof fn === "function") { + componentFrameCache.set(fn, _frame); + } + } + return _frame; + } + } while (s >= 1 && c >= 0); + } + break; + } + } + } + } finally { + reentry = false; + { + ReactCurrentDispatcher.current = previousDispatcher; + reenableLogs(); + } + Error.prepareStackTrace = previousPrepareStackTrace; + } + var name = fn ? fn.displayName || fn.name : ""; + var syntheticFrame = name ? describeBuiltInComponentFrame(name) : ""; + { + if (typeof fn === "function") { + componentFrameCache.set(fn, syntheticFrame); + } + } + return syntheticFrame; + } + function describeClassComponentFrame(ctor, source, ownerFn) { + { + return describeNativeComponentFrame(ctor, true); + } + } + function describeFunctionComponentFrame(fn, source, ownerFn) { + { + return describeNativeComponentFrame(fn, false); + } + } + function shouldConstruct(Component2) { + var prototype = Component2.prototype; + return !!(prototype && prototype.isReactComponent); + } + function describeUnknownElementTypeFrameInDEV(type, source, ownerFn) { + if (type == null) { + return ""; + } + if (typeof type === "function") { + { + return describeNativeComponentFrame(type, shouldConstruct(type)); + } + } + if (typeof type === "string") { + return describeBuiltInComponentFrame(type); + } + switch (type) { + case REACT_SUSPENSE_TYPE: + return describeBuiltInComponentFrame("Suspense"); + case REACT_SUSPENSE_LIST_TYPE: + return describeBuiltInComponentFrame("SuspenseList"); + } + if (typeof type === "object") { + switch (type.$$typeof) { + case REACT_FORWARD_REF_TYPE: + return describeFunctionComponentFrame(type.render); + case REACT_MEMO_TYPE: + return describeUnknownElementTypeFrameInDEV(type.type, source, ownerFn); + case REACT_LAZY_TYPE: { + var lazyComponent = type; + var payload = lazyComponent._payload; + var init = lazyComponent._init; + try { + return describeUnknownElementTypeFrameInDEV(init(payload), source, ownerFn); + } catch (x) { + } + } + } + } + return ""; + } + function describeFiber(fiber) { + var owner = fiber._debugOwner ? fiber._debugOwner.type : null; + var source = fiber._debugSource; + switch (fiber.tag) { + case HostComponent: + return describeBuiltInComponentFrame(fiber.type); + case LazyComponent: + return describeBuiltInComponentFrame("Lazy"); + case SuspenseComponent: + return describeBuiltInComponentFrame("Suspense"); + case SuspenseListComponent: + return describeBuiltInComponentFrame("SuspenseList"); + case FunctionComponent: + case IndeterminateComponent: + case SimpleMemoComponent: + return describeFunctionComponentFrame(fiber.type); + case ForwardRef: + return describeFunctionComponentFrame(fiber.type.render); + case ClassComponent: + return describeClassComponentFrame(fiber.type); + default: + return ""; + } + } + function getStackByFiberInDevAndProd(workInProgress2) { + try { + var info = ""; + var node = workInProgress2; + do { + info += describeFiber(node); + node = node.return; + } while (node); + return info; + } catch (x) { + return "\nError generating stack: " + x.message + "\n" + x.stack; + } + } + function getWrappedName(outerType, innerType, wrapperName) { + var displayName = outerType.displayName; + if (displayName) { + return displayName; + } + var functionName = innerType.displayName || innerType.name || ""; + return functionName !== "" ? wrapperName + "(" + functionName + ")" : wrapperName; + } + function getContextName(type) { + return type.displayName || "Context"; + } + function getComponentNameFromType(type) { + if (type == null) { + return null; + } + { + if (typeof type.tag === "number") { + error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."); + } + } + if (typeof type === "function") { + return type.displayName || type.name || null; + } + if (typeof type === "string") { + return type; + } + switch (type) { + case REACT_FRAGMENT_TYPE: + return "Fragment"; + case REACT_PORTAL_TYPE: + return "Portal"; + case REACT_PROFILER_TYPE: + return "Profiler"; + case REACT_STRICT_MODE_TYPE: + return "StrictMode"; + case REACT_SUSPENSE_TYPE: + return "Suspense"; + case REACT_SUSPENSE_LIST_TYPE: + return "SuspenseList"; + } + if (typeof type === "object") { + switch (type.$$typeof) { + case REACT_CONTEXT_TYPE: + var context = type; + return getContextName(context) + ".Consumer"; + case REACT_PROVIDER_TYPE: + var provider = type; + return getContextName(provider._context) + ".Provider"; + case REACT_FORWARD_REF_TYPE: + return getWrappedName(type, type.render, "ForwardRef"); + case REACT_MEMO_TYPE: + var outerName = type.displayName || null; + if (outerName !== null) { + return outerName; + } + return getComponentNameFromType(type.type) || "Memo"; + case REACT_LAZY_TYPE: { + var lazyComponent = type; + var payload = lazyComponent._payload; + var init = lazyComponent._init; + try { + return getComponentNameFromType(init(payload)); + } catch (x) { + return null; + } + } + } + } + return null; + } + function getWrappedName$1(outerType, innerType, wrapperName) { + var functionName = innerType.displayName || innerType.name || ""; + return outerType.displayName || (functionName !== "" ? wrapperName + "(" + functionName + ")" : wrapperName); + } + function getContextName$1(type) { + return type.displayName || "Context"; + } + function getComponentNameFromFiber(fiber) { + var tag = fiber.tag, type = fiber.type; + switch (tag) { + case CacheComponent: + return "Cache"; + case ContextConsumer: + var context = type; + return getContextName$1(context) + ".Consumer"; + case ContextProvider: + var provider = type; + return getContextName$1(provider._context) + ".Provider"; + case DehydratedFragment: + return "DehydratedFragment"; + case ForwardRef: + return getWrappedName$1(type, type.render, "ForwardRef"); + case Fragment3: + return "Fragment"; + case HostComponent: + return type; + case HostPortal: + return "Portal"; + case HostRoot: + return "Root"; + case HostText: + return "Text"; + case LazyComponent: + return getComponentNameFromType(type); + case Mode: + if (type === REACT_STRICT_MODE_TYPE) { + return "StrictMode"; + } + return "Mode"; + case OffscreenComponent: + return "Offscreen"; + case Profiler: + return "Profiler"; + case ScopeComponent: + return "Scope"; + case SuspenseComponent: + return "Suspense"; + case SuspenseListComponent: + return "SuspenseList"; + case TracingMarkerComponent: + return "TracingMarker"; + case ClassComponent: + case FunctionComponent: + case IncompleteClassComponent: + case IndeterminateComponent: + case MemoComponent: + case SimpleMemoComponent: + if (typeof type === "function") { + return type.displayName || type.name || null; + } + if (typeof type === "string") { + return type; + } + break; + } + return null; + } + var ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame; + var current = null; + var isRendering = false; + function getCurrentFiberOwnerNameInDevOrNull() { + { + if (current === null) { + return null; + } + var owner = current._debugOwner; + if (owner !== null && typeof owner !== "undefined") { + return getComponentNameFromFiber(owner); + } + } + return null; + } + function getCurrentFiberStackInDev() { + { + if (current === null) { + return ""; + } + return getStackByFiberInDevAndProd(current); + } + } + function resetCurrentFiber() { + { + ReactDebugCurrentFrame.getCurrentStack = null; + current = null; + isRendering = false; + } + } + function setCurrentFiber(fiber) { + { + ReactDebugCurrentFrame.getCurrentStack = fiber === null ? null : getCurrentFiberStackInDev; + current = fiber; + isRendering = false; + } + } + function getCurrentFiber() { + { + return current; + } + } + function setIsRendering(rendering) { + { + isRendering = rendering; + } + } + function toString(value) { + return "" + value; + } + function getToStringValue(value) { + switch (typeof value) { + case "boolean": + case "number": + case "string": + case "undefined": + return value; + case "object": + { + checkFormFieldValueStringCoercion(value); + } + return value; + default: + return ""; + } + } + var hasReadOnlyValue = { + button: true, + checkbox: true, + image: true, + hidden: true, + radio: true, + reset: true, + submit: true + }; + function checkControlledValueProps(tagName, props) { + { + if (!(hasReadOnlyValue[props.type] || props.onChange || props.onInput || props.readOnly || props.disabled || props.value == null)) { + error("You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`."); + } + if (!(props.onChange || props.readOnly || props.disabled || props.checked == null)) { + error("You provided a `checked` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultChecked`. Otherwise, set either `onChange` or `readOnly`."); + } + } + } + function isCheckable(elem) { + var type = elem.type; + var nodeName = elem.nodeName; + return nodeName && nodeName.toLowerCase() === "input" && (type === "checkbox" || type === "radio"); + } + function getTracker(node) { + return node._valueTracker; + } + function detachTracker(node) { + node._valueTracker = null; + } + function getValueFromNode(node) { + var value = ""; + if (!node) { + return value; + } + if (isCheckable(node)) { + value = node.checked ? "true" : "false"; + } else { + value = node.value; + } + return value; + } + function trackValueOnNode(node) { + var valueField = isCheckable(node) ? "checked" : "value"; + var descriptor = Object.getOwnPropertyDescriptor(node.constructor.prototype, valueField); + { + checkFormFieldValueStringCoercion(node[valueField]); + } + var currentValue = "" + node[valueField]; + if (node.hasOwnProperty(valueField) || typeof descriptor === "undefined" || typeof descriptor.get !== "function" || typeof descriptor.set !== "function") { + return; + } + var get2 = descriptor.get, set2 = descriptor.set; + Object.defineProperty(node, valueField, { + configurable: true, + get: function() { + return get2.call(this); + }, + set: function(value) { + { + checkFormFieldValueStringCoercion(value); + } + currentValue = "" + value; + set2.call(this, value); + } + }); + Object.defineProperty(node, valueField, { + enumerable: descriptor.enumerable + }); + var tracker = { + getValue: function() { + return currentValue; + }, + setValue: function(value) { + { + checkFormFieldValueStringCoercion(value); + } + currentValue = "" + value; + }, + stopTracking: function() { + detachTracker(node); + delete node[valueField]; + } + }; + return tracker; + } + function track(node) { + if (getTracker(node)) { + return; + } + node._valueTracker = trackValueOnNode(node); + } + function updateValueIfChanged(node) { + if (!node) { + return false; + } + var tracker = getTracker(node); + if (!tracker) { + return true; + } + var lastValue = tracker.getValue(); + var nextValue = getValueFromNode(node); + if (nextValue !== lastValue) { + tracker.setValue(nextValue); + return true; + } + return false; + } + function getActiveElement(doc) { + doc = doc || (typeof document !== "undefined" ? document : void 0); + if (typeof doc === "undefined") { + return null; + } + try { + return doc.activeElement || doc.body; + } catch (e) { + return doc.body; + } + } + var didWarnValueDefaultValue = false; + var didWarnCheckedDefaultChecked = false; + var didWarnControlledToUncontrolled = false; + var didWarnUncontrolledToControlled = false; + function isControlled(props) { + var usesChecked = props.type === "checkbox" || props.type === "radio"; + return usesChecked ? props.checked != null : props.value != null; + } + function getHostProps(element, props) { + var node = element; + var checked = props.checked; + var hostProps = assign({}, props, { + defaultChecked: void 0, + defaultValue: void 0, + value: void 0, + checked: checked != null ? checked : node._wrapperState.initialChecked + }); + return hostProps; + } + function initWrapperState(element, props) { + { + checkControlledValueProps("input", props); + if (props.checked !== void 0 && props.defaultChecked !== void 0 && !didWarnCheckedDefaultChecked) { + error("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://reactjs.org/link/controlled-components", getCurrentFiberOwnerNameInDevOrNull() || "A component", props.type); + didWarnCheckedDefaultChecked = true; + } + if (props.value !== void 0 && props.defaultValue !== void 0 && !didWarnValueDefaultValue) { + error("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://reactjs.org/link/controlled-components", getCurrentFiberOwnerNameInDevOrNull() || "A component", props.type); + didWarnValueDefaultValue = true; + } + } + var node = element; + var defaultValue = props.defaultValue == null ? "" : props.defaultValue; + node._wrapperState = { + initialChecked: props.checked != null ? props.checked : props.defaultChecked, + initialValue: getToStringValue(props.value != null ? props.value : defaultValue), + controlled: isControlled(props) + }; + } + function updateChecked(element, props) { + var node = element; + var checked = props.checked; + if (checked != null) { + setValueForProperty(node, "checked", checked, false); + } + } + function updateWrapper(element, props) { + var node = element; + { + var controlled = isControlled(props); + if (!node._wrapperState.controlled && controlled && !didWarnUncontrolledToControlled) { + error("A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components"); + didWarnUncontrolledToControlled = true; + } + if (node._wrapperState.controlled && !controlled && !didWarnControlledToUncontrolled) { + error("A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components"); + didWarnControlledToUncontrolled = true; + } + } + updateChecked(element, props); + var value = getToStringValue(props.value); + var type = props.type; + if (value != null) { + if (type === "number") { + if (value === 0 && node.value === "" || // We explicitly want to coerce to number here if possible. + // eslint-disable-next-line + node.value != value) { + node.value = toString(value); + } + } else if (node.value !== toString(value)) { + node.value = toString(value); + } + } else if (type === "submit" || type === "reset") { + node.removeAttribute("value"); + return; + } + { + if (props.hasOwnProperty("value")) { + setDefaultValue(node, props.type, value); + } else if (props.hasOwnProperty("defaultValue")) { + setDefaultValue(node, props.type, getToStringValue(props.defaultValue)); + } + } + { + if (props.checked == null && props.defaultChecked != null) { + node.defaultChecked = !!props.defaultChecked; + } + } + } + function postMountWrapper(element, props, isHydrating2) { + var node = element; + if (props.hasOwnProperty("value") || props.hasOwnProperty("defaultValue")) { + var type = props.type; + var isButton = type === "submit" || type === "reset"; + if (isButton && (props.value === void 0 || props.value === null)) { + return; + } + var initialValue = toString(node._wrapperState.initialValue); + if (!isHydrating2) { + { + if (initialValue !== node.value) { + node.value = initialValue; + } + } + } + { + node.defaultValue = initialValue; + } + } + var name = node.name; + if (name !== "") { + node.name = ""; + } + { + node.defaultChecked = !node.defaultChecked; + node.defaultChecked = !!node._wrapperState.initialChecked; + } + if (name !== "") { + node.name = name; + } + } + function restoreControlledState(element, props) { + var node = element; + updateWrapper(node, props); + updateNamedCousins(node, props); + } + function updateNamedCousins(rootNode, props) { + var name = props.name; + if (props.type === "radio" && name != null) { + var queryRoot = rootNode; + while (queryRoot.parentNode) { + queryRoot = queryRoot.parentNode; + } + { + checkAttributeStringCoercion(name, "name"); + } + var group = queryRoot.querySelectorAll("input[name=" + JSON.stringify("" + name) + '][type="radio"]'); + for (var i = 0; i < group.length; i++) { + var otherNode = group[i]; + if (otherNode === rootNode || otherNode.form !== rootNode.form) { + continue; + } + var otherProps = getFiberCurrentPropsFromNode(otherNode); + if (!otherProps) { + throw new Error("ReactDOMInput: Mixing React and non-React radio inputs with the same `name` is not supported."); + } + updateValueIfChanged(otherNode); + updateWrapper(otherNode, otherProps); + } + } + } + function setDefaultValue(node, type, value) { + if ( + // Focused number inputs synchronize on blur. See ChangeEventPlugin.js + type !== "number" || getActiveElement(node.ownerDocument) !== node + ) { + if (value == null) { + node.defaultValue = toString(node._wrapperState.initialValue); + } else if (node.defaultValue !== toString(value)) { + node.defaultValue = toString(value); + } + } + } + var didWarnSelectedSetOnOption = false; + var didWarnInvalidChild = false; + var didWarnInvalidInnerHTML = false; + function validateProps(element, props) { + { + if (props.value == null) { + if (typeof props.children === "object" && props.children !== null) { + React4.Children.forEach(props.children, function(child) { + if (child == null) { + return; + } + if (typeof child === "string" || typeof child === "number") { + return; + } + if (!didWarnInvalidChild) { + didWarnInvalidChild = true; + error("Cannot infer the option value of complex children. Pass a `value` prop or use a plain string as children to
+ ) +} + +// Form Aria page component +function FormAriaPage() { + return ( +
+

Aria Form

+
+

+ + +

+

+ +

+
+
+ ) +} + +// Form Example1 page component +function FormExample1Page() { + return ( +
+

Example Form 1

+
+

+ + +

+

+ +

+
+
+ ) +} + +// Form Example7 page component +function FormExample7Page() { + return ( +
+

Example Form 7

+
+

+ + +

+

+ +

+
+
+ ) +} + +// Form Wait Element page component +function FormWaitElementPage() { + const [showElement, setShowElement] = React.useState(false) + + React.useEffect(() => { + const timer = setTimeout(() => setShowElement(true), 1000) + return () => clearTimeout(timer) + }, []) + + return ( +
+

Wait Element Form

+ {showElement && ( +
+

+ + +

+

+ +

+
+ )} +
+ ) +} + +// Custom Locator Strategies test page +function CustomLocatorStrategiesPage() { + return ( +
+ + +

+ Custom Locator Test Page +

+ +
+

+ This is a test page for custom locators. +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ) +} + +// Login page component +function LoginPage() { + return ( +
+

Login

+
+

+ +
+ +

+

+ +
+ +

+

+ +

+
+

+ Back to index +

+
+ ) +} + +// Bug 1467 form page component +function FormBug1467Page() { + const location = useLocation() + const sessionTag = location.hash ? location.hash.substring(1) : 'default' + + return ( +
+

Bug 1467 Form ({sessionTag})

+

TEST TEST

+ +
+

Form 1

+

+ +

+

+ +

+

+ +

+
+ +
+

Form 2

+

+ +

+

+ +

+

+ +

+
+ +

+ Back to index +

+
+ ) +} + +// Iframe page component +function IframePage() { + return ( +
+

Iframe Test Page

+ +

+ Back to index +

+
+ ) +} + +// Iframe nested page component +function IframeNestedPage() { + return ( +
+

Nested Iframe Test Page

+ +

+ Back to index +

+
+ ) +} + +// Iframe content component (for the simple iframe) +function IframeContentPage() { + return ( + + + Iframe Content + + +

Inside Iframe

+
+

+ + +

+

+ +

+
+ + + ) +} + +// Iframe wrapper component (for nested iframe) +function IframeWrapperPage() { + return ( + + + Iframe Wrapper + + +

Wrapper Iframe

+ + + + ) +} + +// Form Field Values page component (used 39 times in tests) +function FormFieldValuesPage() { + return ( + + + + Tests for seeInField + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + ) +} + +// Form Wait For Clickable page component (used 29 times in tests) +function FormWaitForClickablePage() { + React.useEffect(() => { + const delay = () => { + setTimeout(() => { + const button = document.getElementById('publish_button') + if (button) { + button.removeAttribute('disabled') + button.classList.add('ooops') + } + }, 500) + } + delay() + }, []) + + return ( + + + + + + + + + +
Div not in viewport by top
+
Div not in viewport by bottom
+
Div not in viewport by left
+
Div not in viewport by right
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ + + ) +} + +// Form Popup page component (used 22 times in tests) +function FormPopupPage() { + const [result, setResult] = React.useState('') + + const showConfirm = () => { + const res = window.confirm("Are you sure?") + setResult(res ? 'Yes' : 'No') + } + + const showAlert = () => { + window.alert("Really?") + } + + return ( + + +

Watch our popups

+ +
+ + + +
{result}
+
+ + + ) +} + +// Form Wait Value page component (used 16 times in tests) +function FormWaitValuePage() { + const [value, setValue] = React.useState('') + + React.useEffect(() => { + const timer = setTimeout(() => setValue('test value'), 1000) + return () => clearTimeout(timer) + }, []) + + return ( + + +

Wait for Value

+ +