diff --git a/.claude/commands/e2e-all.md b/.claude/commands/e2e-all.md new file mode 100644 index 000000000000..a8b947bca8a8 --- /dev/null +++ b/.claude/commands/e2e-all.md @@ -0,0 +1,80 @@ +# E2E Test Runner - All Tests (OSS + Enterprise) + +**IMMEDIATELY run all E2E tests (both OSS and enterprise), analyze failures, fix them, and re-run failed tests until all pass or unfixable issues are identified.** + +## Prerequisites +Read `.claude/context/e2e.md` for full E2E configuration details and setup requirements. + +## Workflow + +**IMPORTANT: Always start by changing to the frontend directory** - the `.env` file and dependencies are located there. + +1. **RUN TESTS NOW** from the frontend directory: + ```bash + cd frontend + E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --quiet + ``` + + **Note:** Using `E2E_RETRIES=0` to fail fast on first failure for immediate analysis and fixing. + +2. **If ANY tests fail:** + + For EACH failed test, **ALWAYS READ TRACES FIRST:** + + a. **Read error-context.md** in the failed test directory: + - Check `frontend/e2e/test-results//error-context.md` + - Shows exact DOM state when test failed + - YAML tree with all elements, their states, and data-test attributes + - Check if expected elements exist and their actual values + - Verify selector is correct by searching for the data-test attribute + + b. **If error-context.md doesn't show the issue**, unzip and check trace files: + ```bash + cd frontend/e2e/test-results/ + unzip -q trace.zip + grep -i "error\|failed" 0-trace.network # Check for network errors + ``` + + c. **Only after analysing traces**, read the test file and fix: + - Wrong selector → Update to match actual DOM from error-context.md + - Missing `data-test` attribute → Add it to the component + - Element hidden → Filter for visible elements or wait for visibility + - Missing wait → Add appropriate `waitFor*` calls + - Race condition → Add network waits, increase timeouts, or use more specific waits + - Flaky element interaction → Add `scrollIntoView` or `waitForVisible` before clicking + + d. **After making fixes**, re-run ONLY the failed tests: + ```bash + cd frontend + E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=1 npm run test -- tests/flag-tests.pw.ts tests/invite-test.pw.ts + ``` + - Use `E2E_RETRIES=0` to fail fast and see if the fix worked + - Use concurrency=1 to avoid race conditions + - Only run the specific test files that failed + + e. **Repeat the fix/re-run cycle:** + - If tests still fail after fixes but the error changed, analyse the new error and fix again + - Re-run failed tests after each fix attempt + - Continue until all tests pass or you've identified unfixable issues + - Maximum 3 fix/re-run cycles per test before reporting as unfixable + +3. **Report final results:** + - Break down results by category: + - OSS tests: X passed / Y failed + - Enterprise tests: X passed / Y failed + - Total: X passed / Y failed + - List which tests passed/failed + - Document any fixes that were applied + - For unfixable issues, explain why and suggest manual investigation + - Show the error message and line number for any failures + +## Important Notes + +- **DO NOT** just report that retries passed - if tests failed initially, investigate and fix the root cause +- **DO** modify test files to fix timing issues, missing waits, or broken selectors +- **DO** add `data-test` attributes to components if they're missing +- **DON'T** modify test assertions or business logic unless the test is clearly wrong +- If the failure is in application code (not test code), report it as a bug but don't try to fix it +- Always explain what fixes you're attempting and why +- Test files are in TypeScript and use the `helpers.playwright.ts` helper functions +- The built-in retry mechanism is a safety net, not a substitute for fixing flaky tests diff --git a/.claude/commands/e2e-create.md b/.claude/commands/e2e-create.md new file mode 100644 index 000000000000..d881ba596241 --- /dev/null +++ b/.claude/commands/e2e-create.md @@ -0,0 +1,85 @@ +# E2E Test Creator + +Create a new E2E test following the existing patterns in the codebase. + +## Prerequisites +Read `.claude/context/e2e.md` for full E2E configuration details, test structure, and debugging guides. + +## Workflow + +**IMPORTANT: Always start by changing to the frontend directory** - the `.env` file and dependencies are located there. + +1. **Ask the user what they want to test:** + - What feature/page/flow to test + - Any specific scenarios or edge cases + +2. **Determine if this is OSS or Enterprise:** + - Check if the feature being tested exists in enterprise-only code paths + - Look for clues in existing tests that test similar features + - If unclear, ask the user + - Tag the test with `@oss` or `@enterprise` accordingly + +3. **Review existing test patterns:** + - Read 2-3 similar existing test files from `frontend/e2e/tests/` + - Read `frontend/e2e/helpers.playwright.ts` to understand available utilities + - Note common patterns like: + - Login flow + - Navigation + - Element selection using `data-test` attributes + - Waiting for elements/network + - Assertions + +4. **Scan the relevant application code:** + - Find the components/pages being tested in `frontend/web/components/` or `frontend/common/` + - Check if elements already have `data-test` attributes + - If missing, add `data-test` attributes to make tests reliable: + - Use descriptive names like `data-test="create-feature-btn"` + - Follow existing naming patterns in the codebase + - Add them to buttons, inputs, and key interactive elements + +5. **Create the test file:** + - Follow the naming convention: `*-test.pw.ts` or `*-tests.pw.ts` + - Use the test structure from existing tests + - Use helper functions from `helpers.playwright.ts` + - Include proper test descriptions and tags + - Add comments explaining complex test logic + +6. **Run the new test to verify it works:** + - Run the test 2-3 times to ensure it's stable: + ```bash + cd frontend + SKIP_BUNDLE=1 E2E_CONCURRENCY=1 npm run test -- tests/new-test.pw.ts --quiet + ``` + - **If the test fails, ALWAYS READ TRACES FIRST:** + 1. **Read error-context.md** in the failed test directory: + - Shows exact DOM state when test failed + - YAML tree with all elements, their states, and data-test attributes + - Check if expected elements exist and their actual values + - Verify selector is correct by searching for the data-test attribute + 2. **If error-context.md doesn't show the issue**, unzip and check trace files: + ```bash + cd frontend/e2e/test-results/ + unzip -q trace.zip + grep -i "error\|failed" 0-trace.network # Check for network errors + ``` + 3. **Only after analyzing traces**, fix the issue: + - Wrong selector → Update to match actual DOM from error-context.md + - Missing `data-test` attribute → Add it to the component + - Element hidden → Filter for visible elements or wait for visibility + - Missing wait → Add appropriate `waitFor*` calls + - Re-run until it passes consistently + +7. **Report what was created:** + - Show the test file path + - List any `data-test` attributes that were added + - Report test stability (how many runs passed/failed) + - If there were failures, explain what was fixed + +## Important Notes + +- Always use `data-test` attributes for selectors, not CSS classes or IDs +- Use existing helper functions instead of raw Playwright APIs +- Follow the login pattern from other tests +- Tests should be independent and not rely on order of execution +- Use descriptive test names that explain what's being tested +- Add appropriate waits for async operations diff --git a/.claude/commands/e2e-ee.md b/.claude/commands/e2e-ee.md new file mode 100644 index 000000000000..daf7536a5cc5 --- /dev/null +++ b/.claude/commands/e2e-ee.md @@ -0,0 +1,96 @@ +# E2E Enterprise Test Runner with Auto-Fix + +**IMMEDIATELY run enterprise E2E tests (tagged with @enterprise), analyze failures, fix them, and re-run failed tests until all pass or unfixable issues are identified.** + +## Prerequisites +Read `.claude/context/e2e.md` for full E2E configuration details and setup requirements. + +## Workflow + +**IMPORTANT: Always start by changing to the frontend directory** - the `.env` file and dependencies are located there. + +1. **RUN TESTS NOW** from the frontend directory: + ```bash + cd frontend + E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep @enterprise --quiet + ``` + + **Note:** Using `E2E_RETRIES=0` to fail fast on first failure for immediate analysis and fixing. + +2. **ALWAYS check for flaky tests after test run completes:** + + **CRITICAL:** Tests that fail initially but pass on retry are FLAKY and MUST be investigated, even if the final result shows all tests passed. + + a. Check the test output for any tests that failed on first run (look for the initial failure messages before "Retrying") + + b. For EACH test that failed initially (even if it passed on retry): + - Parse the error from the test output: + * Error type (e.g., `TimeoutError`, `AssertionError`) + * Error message (e.g., `"waiting for locator('#button') to be visible"`) + * File path and line number where it failed + * Stack trace showing the call chain + - Read the test file at the failing line number to understand what was being tested + - **Report these as FLAKY TESTS** - they indicate timing issues, race conditions, or environmental problems + - **Analyze the root cause**: + * Timeout errors → likely missing waits or race conditions + * Assertion errors → check if value is correct or if timing is off + * Element not found → selector may have changed or element loads slowly + + c. **If ANY tests are still failing after automatic retries:** + + For EACH failed test, **ALWAYS READ TRACES FIRST:** + + 1. **Read error-context.md** in the failed test directory: + - Shows exact DOM state when test failed + - YAML tree with all elements, their states, and data-test attributes + - Check if expected elements exist and their actual values + - Verify selector is correct by searching for the data-test attribute + + 2. **If error-context.md doesn't show the issue**, unzip and check trace files: + ```bash + cd frontend/e2e/test-results/ + unzip -q trace.zip + grep -i "error\|failed" 0-trace.network # Check for network errors + ``` + + 3. **Only after analyzing traces**, read the test file and fix: + - Wrong selector → Update to match actual DOM from error-context.md + - Missing `data-test` attribute → Add it to the component + - Element hidden → Filter for visible elements or wait for visibility + - Missing wait → Add appropriate `waitFor*` calls + - Race condition → Add network waits, increase timeouts, or use more specific waits + - Flaky element interaction → Add `scrollIntoView` or `waitForVisible` before clicking + + d. **After making fixes**, re-run ONLY the failed tests: + ```bash + cd frontend + E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=1 npm run test -- tests/flag-tests.pw.ts tests/invite-test.pw.ts --grep @enterprise + ``` + - Use `E2E_RETRIES=0` to fail fast and see if the fix worked + - Use concurrency=1 to avoid race conditions + - Only run the specific test files that failed + - Include `--grep @enterprise` to ensure only enterprise tests run + + e. **Repeat the fix/re-run cycle:** + - If tests still fail after fixes but the error changed, analyze the new error and fix again + - Re-run failed tests after each fix attempt + - Continue until all tests pass or you've identified unfixable issues + - Maximum 3 fix/re-run cycles per test before reporting as unfixable + +3. **Report final results:** + - **ALWAYS report flaky tests first** (tests that failed initially but passed on retry) + - List which tests passed/failed after all retries + - Document any fixes that were applied + - For unfixable issues, explain why and suggest manual investigation + - Show the error message and line number for any failures + +## Important Notes + +- **DO NOT** just report that retries passed - if tests failed initially, investigate and fix the root cause +- **DO** modify test files to fix timing issues, missing waits, or broken selectors +- **DO** add `data-test` attributes to components if they're missing +- **DON'T** modify test assertions or business logic unless the test is clearly wrong +- If the failure is in application code (not test code), report it as a bug but don't try to fix it +- Always explain what fixes you're attempting and why +- Test files are in TypeScript and use the `helpers.playwright.ts` helper functions +- The built-in retry mechanism is a safety net, not a substitute for fixing flaky tests diff --git a/.claude/commands/e2e.md b/.claude/commands/e2e.md new file mode 100644 index 000000000000..2653b0d08fa5 --- /dev/null +++ b/.claude/commands/e2e.md @@ -0,0 +1,95 @@ +# E2E Test Runner with Auto-Fix + +**IMMEDIATELY run OSS (non-enterprise) E2E tests, analyze failures, fix them, and re-run failed tests until all pass or unfixable issues are identified.** + +## Prerequisites +Read `.claude/context/e2e.md` for full E2E configuration details and setup requirements. + +## Workflow + +**IMPORTANT: Always start by changing to the frontend directory** - the `.env` file and dependencies are located there. + +1. **RUN TESTS NOW** from the frontend directory: + ```bash + cd frontend + E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep-invert @enterprise --quiet + ``` + + **Note:** Using `E2E_RETRIES=0` to fail fast on first failure for immediate analysis and fixing. + +2. **ALWAYS check for flaky tests after test run completes:** + + **CRITICAL:** Tests that fail initially but pass on retry are FLAKY and MUST be investigated, even if the final result shows all tests passed. + + a. Check the test output for any tests that failed on first run (look for the initial failure messages before "Retrying") + + b. For EACH test that failed initially (even if it passed on retry): + - Parse the error from the test output: + * Error type (e.g., `TimeoutError`, `AssertionError`) + * Error message (e.g., `"waiting for locator('#button') to be visible"`) + * File path and line number where it failed + * Stack trace showing the call chain + - Read the test file at the failing line number to understand what was being tested + - **Report these as FLAKY TESTS** - they indicate timing issues, race conditions, or environmental problems + - **Analyze the root cause**: + * Timeout errors → likely missing waits or race conditions + * Assertion errors → check if value is correct or if timing is off + * Element not found → selector may have changed or element loads slowly + + c. **If ANY tests are still failing after automatic retries:** + + For EACH failed test, **ALWAYS READ TRACES FIRST:** + + 1. **Read error-context.md** in the failed test directory: + - Shows exact DOM state when test failed + - YAML tree with all elements, their states, and data-test attributes + - Check if expected elements exist and their actual values + - Verify selector is correct by searching for the data-test attribute + + 2. **If error-context.md doesn't show the issue**, unzip and check trace files: + ```bash + cd frontend/e2e/test-results/ + unzip -q trace.zip + grep -i "error\|failed" 0-trace.network # Check for network errors + ``` + + 3. **Only after analyzing traces**, read the test file and fix: + - Wrong selector → Update to match actual DOM from error-context.md + - Missing `data-test` attribute → Add it to the component + - Element hidden → Filter for visible elements or wait for visibility + - Missing wait → Add appropriate `waitFor*` calls + - Race condition → Add network waits, increase timeouts, or use more specific waits + - Flaky element interaction → Add `scrollIntoView` or `waitForVisible` before clicking + + d. **After making fixes**, re-run ONLY the failed tests: + ```bash + cd frontend + E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=1 npm run test -- tests/flag-tests.pw.ts tests/invite-test.pw.ts + ``` + - Use `E2E_RETRIES=0` to fail fast and see if the fix worked + - Use concurrency=1 to avoid race conditions + - Only run the specific test files that failed + + e. **Repeat the fix/re-run cycle:** + - If tests still fail after fixes but the error changed, analyze the new error and fix again + - Re-run failed tests after each fix attempt + - Continue until all tests pass or you've identified unfixable issues + - Maximum 3 fix/re-run cycles per test before reporting as unfixable + +3. **Report final results:** + - **ALWAYS report flaky tests first** (tests that failed initially but passed on retry) + - List which tests passed/failed after all retries + - Document any fixes that were applied + - For unfixable issues, explain why and suggest manual investigation + - Show the error message and line number for any failures + +## Important Notes + +- **DO NOT** just report that retries passed - if tests failed initially, investigate and fix the root cause +- **DO** modify test files to fix timing issues, missing waits, or broken selectors +- **DO** add `data-test` attributes to components if they're missing +- **DON'T** modify test assertions or business logic unless the test is clearly wrong +- If the failure is in application code (not test code), report it as a bug but don't try to fix it +- Always explain what fixes you're attempting and why +- Test files are in TypeScript and use the `helpers.playwright.ts` helper functions +- The built-in retry mechanism is a safety net, not a substitute for fixing flaky tests diff --git a/.claude/context/e2e.md b/.claude/context/e2e.md new file mode 100644 index 000000000000..5ca030197d80 --- /dev/null +++ b/.claude/context/e2e.md @@ -0,0 +1,173 @@ +# E2E Testing Configuration and Context + +## Docker Configuration + +To run E2E tests, the following environment variables must be set in `docker-compose.yml` for the `flagsmith` service: + +```yaml +# E2E Testing +E2E_TEST_AUTH_TOKEN: 'some-token' # Authentication token for E2E teardown endpoint +ENABLE_FE_E2E: 'true' # Enables the E2E testing endpoints in the backend +``` + +## Frontend Configuration + +The frontend `.env` file should contain tokens for different environments: + +```bash +E2E_TEST_TOKEN_DEV=some-token +E2E_TEST_TOKEN_LOCAL=some-token +E2E_TEST_TOKEN_STAGING= +E2E_TEST_TOKEN_PROD= +``` + +## Test Organization + +### Test Files +- Location: `frontend/e2e/tests/*.pw.ts` +- Test categories: + - OSS tests: Default tests (use `--grep-invert @enterprise`) + - Enterprise tests: Tagged with `@enterprise` (use `--grep @enterprise`) + +### Test Results +- Results directory: `frontend/e2e/test-results/` +- Failed test artifacts: + - `failed.json` - Summary of only failed tests + - `results.json` - Complete test results (all tests) + - Individual test directories containing: + - `error-context.md` - DOM snapshot at failure point + - `trace.zip` - Detailed execution trace + - Screenshots of failures + +### HTML Report +- Location: `frontend/e2e/playwright-report/` +- Browsable interface for viewing test results + +## Running E2E Tests + +### Prerequisites +1. Ensure Docker is running +2. Start Flagsmith services: `docker compose up -d` +3. Verify E2E is configured: + ```bash + docker exec flagsmith-flagsmith-1 python -c "import os; print('E2E_TEST_AUTH_TOKEN:', os.getenv('E2E_TEST_AUTH_TOKEN')); print('ENABLE_FE_E2E:', os.getenv('ENABLE_FE_E2E'))" + ``` + +### Test Commands + +#### Run OSS Tests +```bash +cd frontend +SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep-invert @enterprise --quiet +``` + +#### Run Enterprise Tests +```bash +cd frontend +SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep @enterprise --quiet +``` + +#### Run Specific Test Files +```bash +cd frontend +SKIP_BUNDLE=1 E2E_CONCURRENCY=1 npm run test -- tests/flag-tests.pw.ts tests/invite-test.pw.ts +``` + +#### Run All Tests (OSS + Enterprise) +```bash +cd frontend +SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --quiet +``` + +#### Fail-Fast Mode (Stop on First Failure) +When debugging, use `E2E_RETRIES=0` to stop immediately after the first test failure without running remaining tests or retrying: +```bash +cd frontend +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep @enterprise --quiet +``` + +**Note**: Playwright will finish running tests that are already in progress (due to parallel execution), but won't start any new tests after the first failure. For truly immediate failure (one test at a time), combine with `E2E_CONCURRENCY=1`: +```bash +cd frontend +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=1 npm run test -- --grep @enterprise --quiet +``` + +### Environment Variables +- `SKIP_BUNDLE=1` - Skip webpack bundle build for faster iteration +- `E2E_CONCURRENCY=20` - Number of parallel test workers (reduce to 1 for debugging) +- `E2E_RETRIES=0` - Disable retries and enable fail-fast mode (stop on first failure) +- `--quiet` - Minimal output +- `--grep @enterprise` - Run only enterprise tests +- `--grep-invert @enterprise` - Run only OSS tests +- `-x` - Stop after first failure (automatically added when `E2E_RETRIES=0`) + +## Backend E2E Implementation + +### Teardown Endpoint +- URL: `/api/v1/e2etests/teardown/` +- Method: POST +- Authentication: Via `X-E2E-Test-Auth-Token` header +- Purpose: Clears test data and re-seeds database between test runs + +### Middleware +The backend uses `E2ETestMiddleware` to: +1. Check for `X-E2E-Test-Auth-Token` header +2. Compare against `E2E_TEST_AUTH_TOKEN` environment variable +3. Set `request.is_e2e = True` if authenticated + +### Settings Required +- `E2E_TEST_AUTH_TOKEN`: Token for authentication +- `ENABLE_FE_E2E`: Must be `True` to enable endpoints + +## Debugging Test Failures + +### Reading Order for Failed Tests +1. **`failed.json`** - Start here for error summary +2. **`error-context.md`** - DOM snapshot showing exact page state +3. **`trace.zip`** - Detailed trace if needed + +### Common Issues and Solutions + +#### Authentication Errors (401) +- Verify `E2E_TEST_AUTH_TOKEN` is set in docker-compose.yml +- Check token matches in frontend `.env` file +- Restart containers after configuration changes + +#### Bad Request Errors (400) +- Ensure `ENABLE_FE_E2E: 'true'` is set in docker-compose.yml +- Restart containers to apply settings + +#### Flaky Tests +- Tests that fail initially but pass on retry indicate: + - Missing waits or race conditions + - Timing issues with element visibility + - Network request timing issues +- Always investigate and fix root causes + +### Test Helpers +- Helper functions: `frontend/e2e/helpers.playwright.ts` +- Use data-test attributes for reliable selectors +- Prefer explicit waits over arbitrary delays + +## Test Infrastructure + +### Playwright Configuration +- Browser: Firefox +- Test runner: `frontend/e2e/run-with-retry.ts` +- Global setup: `frontend/e2e/global-setup.playwright.ts` +- Global teardown: `frontend/e2e/global-teardown.playwright.ts` + +### Retry Logic +- Built-in retry mechanism in `run-with-retry.ts` +- Retries are a safety net, not a solution for flaky tests +- All flaky tests must be investigated and fixed + +## Best Practices + +1. **Always run from frontend directory** - Dependencies and .env file are there +2. **Check for flaky tests** - Even if they pass on retry +3. **Read error-context.md first** - Shows exact DOM state at failure +4. **Use low concurrency for debugging** - Set E2E_CONCURRENCY=1 +5. **Don't ignore timing issues** - Fix root causes of flakiness +6. **Add data-test attributes** - For reliable element selection +7. **Document fixes** - Explain what was changed and why \ No newline at end of file diff --git a/.github/actions/e2e-tests/action.yml b/.github/actions/e2e-tests/action.yml index 3c6cd53110b6..8339d34f630d 100644 --- a/.github/actions/e2e-tests/action.yml +++ b/.github/actions/e2e-tests/action.yml @@ -26,13 +26,26 @@ runs: node-version-file: frontend/.nvmrc cache-dependency-path: frontend/package-lock.json + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: NPM Install working-directory: frontend run: | npm ci shell: bash - - name: Test with Chromedriver + - name: Install Playwright browsers + working-directory: frontend + run: npm run test:install + shell: bash + + - name: Run E2E tests uses: nick-fields/retry@v3 with: shell: bash diff --git a/.github/workflows/.reusable-docker-e2e-tests.yml b/.github/workflows/.reusable-docker-e2e-tests.yml index 21a5b6a5db88..cd6e6efc4268 100644 --- a/.github/workflows/.reusable-docker-e2e-tests.yml +++ b/.github/workflows/.reusable-docker-e2e-tests.yml @@ -14,7 +14,7 @@ on: required: true args: type: string - description: Additional arguments to testcafe + description: Additional arguments to playwright required: false default: '' concurrency: @@ -44,6 +44,7 @@ jobs: contents: read packages: read id-token: write + pull-requests: write env: GCR_TOKEN: ${{ secrets.GCR_TOKEN }} @@ -52,6 +53,17 @@ jobs: - name: Cloning repo uses: actions/checkout@v5 + - name: Determine test type + id: test-type + run: | + if [[ '${{ inputs.args }}' == *"@enterprise"* ]]; then + echo "type=private-cloud" >> $GITHUB_OUTPUT + echo "label=private-cloud" >> $GITHUB_OUTPUT + else + echo "type=oss" >> $GITHUB_OUTPUT + echo "label=oss" >> $GITHUB_OUTPUT + fi + - name: Login to Github Container Registry if: ${{ env.GCR_TOKEN }} uses: docker/login-action@v3 @@ -67,22 +79,63 @@ jobs: run: depot pull-token | docker login -u x-token --password-stdin registry.depot.dev - name: Run tests on dockerised frontend - uses: nick-fields/retry@v3 - with: - shell: bash - command: | - cd frontend - make test - max_attempts: 2 - retry_on: error - timeout_minutes: 20 - on_retry_command: | - cd frontend - docker compose down --remove-orphans || true + working-directory: frontend + run: make test env: opts: ${{ inputs.args }} API_IMAGE: ${{ inputs.api-image }} E2E_IMAGE: ${{ inputs.e2e-image }} E2E_CONCURRENCY: ${{ inputs.concurrency }} + E2E_RETRIES: 1 SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} GITHUB_ACTION_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + timeout-minutes: 20 + + - name: Cleanup E2E services + if: always() + working-directory: frontend + run: docker compose down --remove-orphans || true + + - name: Copy results.json to HTML report + if: always() + run: | + cp frontend/e2e/test-results/results.json frontend/e2e/playwright-report/ || true + + - name: Upload HTML report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-html-report-${{ steps.test-type.outputs.type }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }} + path: frontend/e2e/playwright-report/ + retention-days: 30 + if-no-files-found: warn + + - name: Set artifact URL + if: failure() && github.event_name == 'pull_request' + id: artifact-url + run: | + echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts" >> $GITHUB_OUTPUT + + - name: Comment PR with test results (success) + if: success() && github.event_name == 'pull_request' + uses: daun/playwright-report-summary@v3 + with: + report-file: frontend/e2e/playwright-report/results.json + comment-title: 'Playwright Test Results (${{ steps.test-type.outputs.label }})' + custom-info: | + **🖥️ Runner:** ${{ inputs.runs-on }} + **🔄 Run:** #${{ github.run_number }} (attempt ${{ github.run_attempt }}) + **📋 Test Type:** ${{ steps.test-type.outputs.label }} + + - name: Comment PR with test results (failure) + if: failure() && github.event_name == 'pull_request' + uses: daun/playwright-report-summary@v3 + with: + report-file: frontend/e2e/playwright-report/results.json + comment-title: 'Playwright Test Results (${{ steps.test-type.outputs.label }})' + custom-info: | + **📦 Artifacts:** [View test results and HTML report](${{ steps.artifact-url.outputs.url }}) + + **🖥️ Runner:** ${{ inputs.runs-on }} + **🔄 Run:** #${{ github.run_number }} (attempt ${{ github.run_attempt }}) + **📋 Test Type:** ${{ steps.test-type.outputs.label }} diff --git a/.github/workflows/frontend-test-staging.yml b/.github/workflows/frontend-test-staging.yml index 89af5d287275..da8f552a281a 100644 --- a/.github/workflows/frontend-test-staging.yml +++ b/.github/workflows/frontend-test-staging.yml @@ -15,22 +15,6 @@ jobs: - name: Cloning repo uses: actions/checkout@v5 - # Temporarily install Firefox 143.0 to avoid test failures as superior versions cause frontend e2e tests to hang - # To be removed once upstream issue correctly resolved - - name: Install Firefox 143.0 - run: | - sudo apt-get remove -y firefox || true - sudo rm -rf /usr/bin/firefox /usr/lib/firefox* - - ARCH=$(uname -m) - wget -O /tmp/firefox.tar.xz "https://ftp.mozilla.org/pub/firefox/releases/143.0/linux-${ARCH}/en-US/firefox-143.0.tar.xz" - sudo tar -xJf /tmp/firefox.tar.xz -C /opt - sudo ln -s /opt/firefox/firefox /usr/local/bin/firefox - rm /tmp/firefox.tar.xz - - firefox --version - - - name: Run E2E tests against staging uses: ./.github/actions/e2e-tests with: diff --git a/.github/workflows/platform-docker-build-e2e-image.yml b/.github/workflows/platform-docker-build-e2e-image.yml deleted file mode 100644 index 6ee4f1f5b8a8..000000000000 --- a/.github/workflows/platform-docker-build-e2e-image.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Build E2E Frontend Base Image - -on: - schedule: - # Update the E2E Firefox testcafe version on the first of every month - - cron: 0 0 1 * * - workflow_dispatch: - -jobs: - build-e2e-docker-image: - name: Build E2E Frontend Base Image - uses: ./.github/workflows/.reusable-docker-build.yml - with: - file: frontend/Dockerfile-base.e2e - image-name: e2e-frontend-base - tags: latest diff --git a/.github/workflows/platform-pull-request.yml b/.github/workflows/platform-pull-request.yml index 61e6e1f8e757..255462500541 100644 --- a/.github/workflows/platform-pull-request.yml +++ b/.github/workflows/platform-pull-request.yml @@ -150,7 +150,7 @@ jobs: runs-on: ${{ matrix.runs-on }} e2e-image: ${{ needs.docker-build-e2e.outputs.image }} api-image: ${{ needs.docker-build-api.outputs.image }} - args: --meta-filter category=oss + args: --grep @oss secrets: GCR_TOKEN: ${{ needs.permissions-check.outputs.can-write == 'true' && secrets.GITHUB_TOKEN || '' }} SLACK_TOKEN: ${{ needs.permissions-check.outputs.can-write == 'true' && secrets.SLACK_TOKEN || '' }} @@ -167,7 +167,7 @@ jobs: runs-on: ${{ matrix.runs-on }} e2e-image: ${{ needs.docker-build-e2e.outputs.image }} api-image: ${{ needs.docker-build-private-cloud.outputs.image }} - args: --meta-filter category=oss,category=enterprise + args: --grep "@oss|@enterprise" secrets: GCR_TOKEN: ${{ needs.permissions-check.outputs.can-write == 'true' && secrets.GITHUB_TOKEN || '' }} SLACK_TOKEN: ${{ needs.permissions-check.outputs.can-write == 'true' && secrets.SLACK_TOKEN || '' }} diff --git a/frontend/.gitignore b/frontend/.gitignore index a551917abb14..29f737c9d966 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -29,3 +29,7 @@ common/project.js # Sentry Config File .env.sentry-build-plugin + +# Playwright +e2e/playwright-report/ +e2e/test-results/ diff --git a/frontend/.testcaferc.js b/frontend/.testcaferc.js deleted file mode 100644 index 43d0102f3722..000000000000 --- a/frontend/.testcaferc.js +++ /dev/null @@ -1,23 +0,0 @@ -const isDev = process.env.E2E_DEV; -module.exports = { - "browsers": "firefox:headless", - "port1": 8080, - "port2": 8081, - "hostname": "localhost", - quarantineMode: false, - skipJsErrors: true, - selectorTimeout: 20000, - assertionTimeout: 20000, - cache: true, - "videoPath": "reports/screen-captures", - "videoOptions": { - "singleFile": true, - "failedOnly": true, - "pathPattern": "./test-report-${FILE_INDEX}.mp4" - }, - "videoEncodingOptions": { - "r": 20, - "aspect": "4:3" - }, - // other settings -} diff --git a/frontend/Dockerfile-base.e2e b/frontend/Dockerfile-base.e2e deleted file mode 100644 index eaa1c3386232..000000000000 --- a/frontend/Dockerfile-base.e2e +++ /dev/null @@ -1,44 +0,0 @@ -FROM debian:latest - -# Set node version -ENV NODE_VERSION 16.20.1 - -# replace shell with bash so we can source files -RUN rm /bin/sh && ln -s /bin/bash /bin/sh - -# Install dependencies -RUN apt-get update && apt-get install -y \ - g++ make libssl-dev python3 python3-setuptools gnupg2 curl wget \ - libgtk-3-0 libdbus-glib-1-2 libxt6 libx11-xcb1 libasound2 \ - && apt-get -y autoclean - -# Download and install Firefox 143.0 -RUN ARCH=$(uname -m) && \ - apt-get install -y xz-utils && \ - wget -O /tmp/firefox.tar.xz "https://ftp.mozilla.org/pub/firefox/releases/143.0/linux-${ARCH}/en-US/firefox-143.0.tar.xz" && \ - tar -xJf /tmp/firefox.tar.xz -C /opt && \ - ln -s /opt/firefox/firefox /usr/local/bin/firefox && \ - rm /tmp/firefox.tar.xz - -# nvm environment variables -ENV NVM_DIR /usr/local/nvm - -# install nvm -# https://github.com/creationix/nvm#install-script -RUN mkdir /usr/local/nvm && curl --silent -o- https://raw.githubusercontent.com/creationix/nvm/v0.39.4/install.sh | bash - -# install node and npm LTS -RUN source $NVM_DIR/nvm.sh \ - && nvm install $NODE_VERSION \ - && nvm alias default $NODE_VERSION \ - && nvm use default - -# add node and npm to path so the commands are available -ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules -ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH - -# confirm installation of node -RUN node -v -RUN npm -v - -CMD ["bash", "-l"] diff --git a/frontend/Dockerfile.e2e b/frontend/Dockerfile.e2e index b0e536225318..e6d6a3a2d4cb 100644 --- a/frontend/Dockerfile.e2e +++ b/frontend/Dockerfile.e2e @@ -1,4 +1,4 @@ -FROM ghcr.io/flagsmith/e2e-frontend-base:latest +FROM node:22-bookworm # Build Flagsmith WORKDIR /srv/flagsmith @@ -10,6 +10,10 @@ COPY frontend/env/ ./env/ ENV ENV=e2e RUN npm ci +# Install Playwright browsers with system dependencies +# This ensures the correct Firefox version matching the Playwright version in package.json +RUN npx playwright install --with-deps firefox + COPY frontend . COPY .release-please-manifest.json ./.versions.json RUN npm run env diff --git a/frontend/E2E-LOCAL-TESTING.md b/frontend/E2E-LOCAL-TESTING.md new file mode 100644 index 000000000000..72da4f96e245 --- /dev/null +++ b/frontend/E2E-LOCAL-TESTING.md @@ -0,0 +1,103 @@ +# Running E2E Tests Locally + +## Quick Start + +From the `frontend/` directory: + +```bash +# Run all tests in Docker (like CI) +make test + +# Run specific tests +make test opts="--grep @oss" + +# Run with custom concurrency +E2E_CONCURRENCY=1 make test +``` + +## Environment Variables + +- `E2E_CONCURRENCY`: Number of parallel test workers (default: 3) +- `E2E_RETRIES`: Number of times to retry failed tests (default: 1) +- `SKIP_BUNDLE`: Skip bundle build step (`SKIP_BUNDLE=1`) +- `VERBOSE`: Show detailed output (`VERBOSE=1`, quiet by default) + +## Running Tests + +### Docker (matches CI exactly) +```bash +# All OSS tests +make test opts="--grep @oss" + +# All tests (OSS + Enterprise) +make test opts="--grep '@oss|@enterprise'" + +# Specific test file +make test opts="tests/flag-tests.pw.ts" + +# Skip bundle build for faster iteration +SKIP_BUNDLE=1 make test opts="tests/flag-tests.pw.ts" + +# Verbose output +VERBOSE=1 make test +``` + +### Keep Services Running +```bash +# Start services +docker compose -f docker-compose-e2e-tests.yml up -d + +# Run tests (with retry logic) +docker compose -f docker-compose-e2e-tests.yml run --rm frontend \ + npm run test -- --grep @oss + +# Re-run without rebuilding +SKIP_BUNDLE=1 docker compose -f docker-compose-e2e-tests.yml run --rm frontend \ + npm run test -- --grep @oss + +# Check logs +docker compose -f docker-compose-e2e-tests.yml logs -f flagsmith-api + +# Cleanup +docker compose -f docker-compose-e2e-tests.yml down +``` + +## Test Results + +Results are saved to `e2e/test-results/` and `e2e/playwright-report/`: + +```bash +# View HTML report +npx playwright show-report e2e/playwright-report + +# Or open directly +open e2e/playwright-report/index.html +``` + +## Retry Behavior + +Tests automatically retry on failure: +1. First attempt runs all tests +2. On failure, runs teardown to clean test data +3. Retries only failed tests (via `--last-failed`) +4. Controlled by `E2E_RETRIES` (default: 1 retry) + +## Troubleshooting + +### Rebuild images +```bash +docker compose -f docker-compose-e2e-tests.yml build --no-cache +``` + +### Port conflicts +```bash +docker compose -f docker-compose-e2e-tests.yml down +lsof -ti:8000 # Check what's using port 8000 +``` + +### Using CI images +```bash +export API_IMAGE=ghcr.io/flagsmith/flagsmith-api:pr-1234 +export E2E_IMAGE=ghcr.io/flagsmith/flagsmith-e2e:pr-1234 +make test +``` diff --git a/frontend/Makefile b/frontend/Makefile index f326515322d6..7108321359e9 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -28,6 +28,16 @@ serve: .PHONY: test test: - docker compose run frontend \ + @echo "Running E2E tests..." + @docker compose run --name e2e-test-run frontend \ npx cross-env E2E_CONCURRENCY=${E2E_CONCURRENCY} npm run test -- $(opts) \ - || (docker compose logs flagsmith-api; exit 1) + || TEST_FAILED=1; \ + echo "Copying test results from container..."; \ + docker cp e2e-test-run:/srv/flagsmith/e2e/test-results ./e2e/test-results 2>/dev/null || echo "No test results to copy"; \ + docker cp e2e-test-run:/srv/flagsmith/e2e/playwright-report ./e2e/playwright-report 2>/dev/null || echo "No HTML report to copy"; \ + docker rm e2e-test-run 2>/dev/null || true; \ + if [ "$$TEST_FAILED" = "1" ]; then \ + echo "\n=== API logs ===" && docker compose logs flagsmith-api && \ + echo "\n=== Frontend logs (includes test results) ===" && docker compose logs frontend && \ + exit 1; \ + fi diff --git a/frontend/bin/upload-file.js b/frontend/bin/upload-file.js index 95f62d490bd8..f6655b74a47d 100644 --- a/frontend/bin/upload-file.js +++ b/frontend/bin/upload-file.js @@ -1,31 +1,44 @@ require('dotenv').config() const { WebClient } = require('@slack/web-api') const fs = require('fs') +const path = require('path') const SLACK_TOKEN = process.env.SLACK_TOKEN -module.exports = function uploadFile(path) { +module.exports = function uploadFile(filePath) { if (!SLACK_TOKEN) { // eslint-disable-next-line console.log('Slack token not specified, skipping upload'); return } - const title = 'Test Run' // Optional + const title = 'Test Run' const epoch = new Date().valueOf() - const filename = `e2e-record-${epoch}.mp4` + const ext = path.extname(filePath) + const basename = path.basename(filePath) + + // Determine filename and comment based on file type + let filename + let comment + if (basename === 'playwright-report.zip') { + filename = `playwright-report-${epoch}.zip` + comment = `📊 Playwright HTML Report ${process.env.GITHUB_ACTION_URL || ''}` + } else { + filename = `e2e-artifact-${epoch}${ext}` + comment = `✖ ${title} ${process.env.GITHUB_ACTION_URL || ''}` + } + const channelId = 'C0102JZRG3G' // infra_tests channel ID // eslint-disable-next-line - console.log(`Uploading ${path}`); + console.log(`Uploading ${filePath}`); const slackClient = new WebClient(SLACK_TOKEN) // Call the files.upload method using the WebClient return slackClient.files.uploadV2({ channel_id: channelId, - file: fs.createReadStream(path), + file: fs.createReadStream(filePath), filename, - initial_comment: `✖ ${title} ${process.env.GITHUB_ACTION_URL || ''}`, + initial_comment: comment, }) } -new Date().valueOf() diff --git a/frontend/e2e/add-error-logs.js b/frontend/e2e/add-error-logs.js deleted file mode 100644 index 9c90caa0e7e0..000000000000 --- a/frontend/e2e/add-error-logs.js +++ /dev/null @@ -1,4 +0,0 @@ -let onError = window.onError -window.onerror = (message, source, lineno, colno, error) => { - console.error(message + source + lineno + colno + error); -} diff --git a/frontend/e2e/extract-failed-tests.ts b/frontend/e2e/extract-failed-tests.ts new file mode 100644 index 000000000000..31b344326055 --- /dev/null +++ b/frontend/e2e/extract-failed-tests.ts @@ -0,0 +1,97 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +function findErrorContextFiles(testResultsDir: string): Record { + const errorContextMap: Record = {}; + + if (!fs.existsSync(testResultsDir)) { + return errorContextMap; + } + + const entries = fs.readdirSync(testResultsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const errorContextPath = path.join(testResultsDir, entry.name, 'error-context.md'); + if (fs.existsSync(errorContextPath)) { + // Use directory name as key - it usually contains the test file name + errorContextMap[entry.name] = errorContextPath; + } + } + } + + return errorContextMap; +} + +export function extractFailedTests(baseDir: string = __dirname): void { + const resultsPath = path.join(baseDir, 'test-results', 'results.json'); + const failedPath = path.join(baseDir, 'test-results', 'failed.json'); + const testResultsDir = path.join(baseDir, 'test-results'); + + if (!fs.existsSync(resultsPath)) { + console.log('No results.json found at:', resultsPath); + return; + } + + console.log('Extracting failed tests from:', resultsPath); + + // Find all error-context.md files + const errorContextFiles = findErrorContextFiles(testResultsDir); + if (Object.keys(errorContextFiles).length > 0) { + console.log(`Found ${Object.keys(errorContextFiles).length} error-context.md file(s)`); + } + + try { + const results = JSON.parse(fs.readFileSync(resultsPath, 'utf-8')); + const failedTests = results.suites?.flatMap((suite: any) => + suite.specs?.filter((spec: any) => + spec.tests?.some((test: any) => + test.results?.some((result: any) => + result.status === 'failed' || result.status === 'timedOut' + ) + ) + ).map((spec: any) => { + // Try to find matching error-context.md file + let errorContextPath: string | undefined; + const testFileName = suite.file.replace(/^tests\//, '').replace(/\.pw\.ts$/, ''); + + for (const [dirName, contextPath] of Object.entries(errorContextFiles)) { + if (dirName.includes(testFileName)) { + errorContextPath = contextPath; + break; + } + } + + return { + file: suite.file, + title: spec.title, + errorContextPath: errorContextPath ? path.relative(testResultsDir, errorContextPath) : undefined, + tests: spec.tests.flatMap((test: any) => + test.results + ?.filter((result: any) => result.status === 'failed' || result.status === 'timedOut') + .map((result: any) => ({ + status: result.status, + error: result.error, + duration: result.duration, + retry: result.retry, + })) || [] + ) + }; + }) + ).filter(Boolean) || []; + + if (failedTests.length > 0) { + fs.writeFileSync(failedPath, JSON.stringify({ failedTests, timestamp: new Date().toISOString() }, null, 2)); + console.log(`Created failed.json with ${failedTests.length} failed test(s) at:`, failedPath); + } else { + console.log('No failed tests found - all tests passed!'); + } + } catch (error) { + console.log('Error creating failed.json:', error); + } +} + +// Allow running as a script +if (require.main === module) { + extractFailedTests(); +} diff --git a/frontend/e2e/global-setup.playwright.ts b/frontend/e2e/global-setup.playwright.ts new file mode 100644 index 000000000000..fcb0f644511b --- /dev/null +++ b/frontend/e2e/global-setup.playwright.ts @@ -0,0 +1,117 @@ +import { FullConfig } from '@playwright/test'; +import fetch from 'node-fetch'; +import flagsmith from 'flagsmith/isomorphic'; +import Project from '../common/project'; +import * as fs from 'fs'; +import * as path from 'path'; + +async function globalSetup(config: FullConfig) { + console.log('Starting global setup for E2E tests...'); + + // Clear previous test results and reports (skip on retries to preserve failed.json) + const testResultsDir = path.join(__dirname, 'test-results'); + const reportDir = path.join(__dirname, 'playwright-report'); + + if (!process.env.E2E_SKIP_CLEANUP) { + // Clean up test-results completely on fresh runs + if (fs.existsSync(testResultsDir)) { + fs.rmSync(testResultsDir, { recursive: true, force: true }); + console.log('Cleared previous test results'); + } + + if (fs.existsSync(reportDir)) { + fs.rmSync(reportDir, { recursive: true, force: true }); + console.log('Cleared previous HTML report'); + } + } else { + console.log('Skipping cleanup (retry attempt)'); + } + + // Ensure test-results directory exists for the JSON reporter + if (!fs.existsSync(testResultsDir)) { + fs.mkdirSync(testResultsDir, { recursive: true }); + } + + const e2eTestApi = `${process.env.FLAGSMITH_API_URL || Project.api}e2etests/teardown/`; + const token = process.env.E2E_TEST_TOKEN + ? process.env.E2E_TEST_TOKEN + : process.env[`E2E_TEST_TOKEN_${Project.env.toUpperCase()}`] + + console.log( + '\n', + '\x1b[32m', + `E2E using API: ${e2eTestApi}. E2E URL: http://localhost:${process.env.PORT || 8080}`, + '\x1b[0m', + '\n', + ); + + // Initialize Flagsmith + await flagsmith.init({ + api: Project.flagsmithClientAPI, + environmentID: Project.flagsmith, + fetch, + }); + + // Teardown previous test data with retry logic + if (token) { + const maxAttempts = 3; + const delayMs = 2000; + let teardownSuccess = false; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (attempt > 0) { + console.log(`\x1b[33m%s\x1b[0m`, `Retrying teardown (attempt ${attempt + 1}/${maxAttempts})...`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + + try { + const res = await fetch(e2eTestApi, { + body: JSON.stringify({}), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-E2E-Test-Auth-Token': token.trim(), + }, + method: 'POST', + }); + + if (res.ok) { + console.log('\n', '\x1b[32m', 'e2e teardown successful', '\x1b[0m', '\n'); + teardownSuccess = true; + break; + } else { + console.error('\x1b[31m%s\x1b[0m', `✗ E2E teardown failed: ${res.status}`); + if (attempt < maxAttempts - 1) { + console.log(''); + } + } + } catch (error) { + console.error('\x1b[31m%s\x1b[0m', `✗ E2E teardown error: ${error.message || String(error)}`); + if (attempt < maxAttempts - 1) { + console.log(''); + } + } + } + + if (!teardownSuccess) { + const errorMsg = `e2e teardown failed after ${maxAttempts} attempts`; + console.error('\n', '\x1b[31m', errorMsg, '\x1b[0m', '\n'); + if (process.env.E2E_LOCAL !== 'true' && process.env.E2E_DEV !== 'true') { + throw new Error(errorMsg); // Fail tests early in CI if teardown fails + } + } + } else { + // Only warn for local/dev testing, don't fail + if (process.env.E2E_LOCAL === 'true' || process.env.E2E_DEV === 'true') { + console.log('\n', '\x1b[33m', 'e2e teardown skipped (no token) - OK for local testing', '\x1b[0m', '\n'); + } else { + const errorMsg = 'e2e teardown failed - no available token (set E2E_TEST_TOKEN or E2E_TEST_TOKEN_)'; + console.error('\n', '\x1b[31m', errorMsg, '\x1b[0m', '\n'); + throw new Error(errorMsg); // Fail tests in CI if token is missing + } + } + + console.log('Starting E2E tests'); +} + +export default globalSetup; diff --git a/frontend/e2e/global-teardown.playwright.ts b/frontend/e2e/global-teardown.playwright.ts new file mode 100644 index 000000000000..b0fa0afe4299 --- /dev/null +++ b/frontend/e2e/global-teardown.playwright.ts @@ -0,0 +1,97 @@ +import { FullConfig } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as archiver from 'archiver'; +import { extractFailedTests } from './extract-failed-tests'; + +let upload: ((file: string) => Promise) | null = null; +try { + upload = require('../bin/upload-file'); +} catch (e) { + console.log('Upload module not available:', e.message); + // upload-file module not available, will skip uploading +} + +async function zipDirectory(sourceDir: string, outPath: string): Promise { + // @ts-ignore + const archive = archiver('zip', { zlib: { level: 9 } }); + const stream = fs.createWriteStream(outPath); + + return new Promise((resolve, reject) => { + archive + .directory(sourceDir, false) + .on('error', err => reject(err)) + .pipe(stream); + + stream.on('close', () => resolve()); + archive.finalize(); + }); +} + +async function globalTeardown(config: FullConfig) { + console.log('Running global teardown for E2E tests...'); + + // Extract failed tests to a smaller JSON file for easier debugging + extractFailedTests(__dirname); + + // Upload screenshots/videos if they exist and not in dev mode + const dir = path.join(__dirname, 'test-results'); + + if (fs.existsSync(dir) && !process.env.E2E_DEV) { + try { + console.log('Uploading test artifacts...'); + const files: string[] = []; + + // Recursively find only failed test artifacts + const findFiles = (currentDir: string) => { + const items = fs.readdirSync(currentDir); + for (const item of items) { + const fullPath = path.join(currentDir, item); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + findFiles(fullPath); + } else if (item.match(/\.(png|jpg|jpeg|mp4|webm)$/i)) { + // Only upload artifacts from failed tests + if (fullPath.includes('test-failed')) { + files.push(fullPath); + } + } + } + }; + + findFiles(dir); + + if (files.length > 0 && upload) { + await Promise.all(files.map(f => upload!(f))); + console.log(`Uploaded ${files.length} test artifacts`); + } else if (files.length > 0) { + console.log(`Found ${files.length} test artifacts but upload module not available`); + } + + // Upload HTML report if it exists + const reportDir = path.join(__dirname, 'playwright-report'); + if (fs.existsSync(reportDir) && upload) { + try { + console.log('Zipping Playwright HTML report...'); + const zipPath = path.join(__dirname, 'playwright-report.zip'); + await zipDirectory(reportDir, zipPath); + console.log('Uploading Playwright HTML report...'); + await upload(zipPath); + console.log('Uploaded Playwright HTML report'); + // Clean up zip file + fs.unlinkSync(zipPath); + } catch (e) { + console.log('Error uploading HTML report:', e); + } + } + } catch (e) { + console.log('Error uploading files:', e); + } + } else { + console.log('No files to upload'); + } + + console.log('E2E tests completed'); +} + +export default globalTeardown; diff --git a/frontend/e2e/helpers.cafe.ts b/frontend/e2e/helpers.cafe.ts deleted file mode 100644 index 2631bb0f4632..000000000000 --- a/frontend/e2e/helpers.cafe.ts +++ /dev/null @@ -1,679 +0,0 @@ -import { RequestLogger, Selector, t } from 'testcafe' -import Project from '../common/project' -import fetch from 'node-fetch' -import flagsmith from 'flagsmith/isomorphic' -import { IFlagsmith, FlagsmithValue } from 'flagsmith/types' - -export const LONG_TIMEOUT = 40000 - -export const byId = (id: string) => `[data-test="${id}"]` - -export type MultiVariate = { value: string; weight: number } - -export type Rule = { - name: string - operator: string - value: string | number | boolean - ors?: Rule[] -} - -// Allows to check if an element is present - can be used to identify active feature flag state -export const isElementExists = async (selector: string) => { - return Selector(byId(selector)).exists -} - -const initProm = flagsmith.init({ - api: Project.flagsmithClientAPI, - environmentID: Project.flagsmith, - fetch, -}) -export const getFlagsmith = async function () { - await initProm - return flagsmith as IFlagsmith -} -export const setText = async (selector: string, text: string) => { - logUsingLastSection(`Set text ${selector} : ${text}`) - if (text) { - return t - .selectText(selector) - .pressKey('delete') - .selectText(selector) // Prevents issue where input tabs out of focus - .typeText(selector, `${text}`) - } else { - return t - .selectText(selector) // Prevents issue where input tabs out of focus - .pressKey('delete') - } -} - -export const waitForElementVisible = async (selector: string) => { - logUsingLastSection(`Waiting element visible ${selector}`) - return t - .expect(Selector(selector).visible) - .ok(`waitForElementVisible(${selector})`, { timeout: LONG_TIMEOUT }) -} - -export const waitForElementNotClickable = async (selector: string) => { - logUsingLastSection(`Waiting element visible ${selector}`) - await t - .expect(Selector(selector).visible) - .ok(`waitForElementVisible(${selector})`, { timeout: LONG_TIMEOUT }) - await t.expect(Selector(selector).hasAttribute('disabled')).ok() -} - -export const waitForElementClickable = async (selector: string) => { - logUsingLastSection(`Waiting element visible ${selector}`) - await t - .expect(Selector(selector).visible) - .ok(`waitForElementVisible(${selector})`, { timeout: LONG_TIMEOUT }) - await t.expect(Selector(selector).hasAttribute('disabled')).notOk() -} - -export const clickSegmentByName = async (name: string) => { - const el = Selector('[data-test^="segment-"][data-test$="-name"]').withText( - name, - ) - await t.scrollIntoView(el) - await t - .expect(el.visible) - .ok(`segment "${name}" not visible`, { timeout: LONG_TIMEOUT }) - await t.click(el) -} - -export const logResults = async (requests: LoggedRequest[], t) => { - if (!t.testRun?.errs?.length) { - log('Finished without errors') - return // do not log anything for passed tests - } - log('Start of Requests') - log( - undefined, - JSON.stringify( - requests.filter((v) => { - if ( - v.request?.url?.includes('get-subscription-metadata') || - v.request?.url?.includes('analytics/flags') || - v.request?.url?.includes('/usage-data?') - ) { - return false - } - if ( - v.response && - v.response?.statusCode >= 200 && - v.response?.statusCode < 300 - ) { - return false - } - return true - }), - null, - 2, - ), - ) - logUsingLastSection('Session JavaScript Errors') - logUsingLastSection(JSON.stringify(await t.getBrowserConsoleMessages())) - log('End of Requests') -} - -export const waitForElementNotExist = async (selector: string) => { - logUsingLastSection(`Waiting element not visible ${selector}`) - return t.expect(Selector(selector).exists).notOk('', { timeout: 10000 }) -} -export const gotoFeatures = async () => { - await click('#features-link') - await waitForElementVisible('#show-create-feature-btn') -} - -export const click = async (selector: string) => { - await waitForElementVisible(selector) - await t - .scrollIntoView(selector) - .expect(Selector(selector).hasAttribute('disabled')) - .notOk('ready for testing', { timeout: 5000 }) - .hover(selector) - .click(selector) -} - -export const clickByText = async (text: string, element = 'button') => { - logUsingLastSection(`Click by text ${text} ${element}`) - const selector = Selector(element).withText(text) - await t - .scrollIntoView(selector) - .expect(Selector(selector).hasAttribute('disabled')) - .notOk('ready for testing', { timeout: 5000 }) - .hover(selector) - .click(selector) -} - -export const gotoSegments = async () => { - await click('#segments-link') - await waitForElementVisible(byId('show-create-segment-btn')) -} - -export const getLogger = () => - RequestLogger(/api\/v1/, { - logRequestBody: true, - logRequestHeaders: true, - logResponseBody: true, - logResponseHeaders: true, - stringifyRequestBody: true, - stringifyResponseBody: true, - }) - -export const checkApiRequest = ( - urlPattern: RegExp, - method: 'get' | 'post' | 'put' | 'patch' | 'delete', -) => - RequestLogger( - (req) => req.url.match(urlPattern) && req.method === method, - { - logRequestBody: true, - logRequestHeaders: true, - }, - ) - -export const createRole = async ( - roleName: string, - index: number, - users: number[], -) => { - await click(byId('tab-item-roles')) - await click(byId('create-role')) - await setText(byId('role-name'), roleName) - await click(byId('save-role')) - await click(byId(`role-${index}`)) - await click(byId('members-tab')) - await click(byId('assigned-users')) - for (const userId of users) { - await click(byId(`assignees-list-item-${userId}`)) - } - await closeModal() -} - -export const editRoleMembers = async (index: number) => { - await click(byId('tab-item-roles')) - await click(byId('create-role')) - await setText(byId('role-name'), roleName) - await click(byId('save-role')) -} - -export const gotoTraits = async () => { - await click('#features-link') - await click('#users-link') - await click(byId('user-item-0')) - await waitForElementVisible('#add-trait') -} - -export const createTrait = async ( - index: number, - id: string, - value: string | boolean | number, -) => { - await click('#add-trait') - await waitForElementVisible('#create-trait-modal') - await setText('[name="traitID"]', id) - await setText('[name="traitValue"]', `${value}`) - await click('#create-trait-btn') - await t.wait(2000) - await t.eval(() => location.reload()) - await waitForElementVisible(byId(`user-trait-value-${index}`)) - const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}` - await assertTextContent(byId(`user-trait-value-${index}`), expectedValue) -} - -export const deleteTrait = async (index: number) => { - await click(byId(`delete-user-trait-${index}`)) - await click('#confirm-btn-yes') - await waitForElementNotExist(byId(`user-trait-${index}`)) -} - -const lastTestSection = {} -let lastTestName = undefined - -export const logUsingLastSection = (message?: string) => { - log(undefined, message) -} - -// eslint-disable-next-line no-console -export const log = (section: string | undefined, message?: string) => { - const testName = t.test.name - const sectionName = section ?? lastTestSection[testName] - - if (lastTestName !== testName || lastTestSection[testName] !== sectionName) { - const ellipsis = section === sectionName ? '' : '...' - console.log( - '\n', - '\x1b[32m', - `${testName ? `${ellipsis}[${testName} tests] ` : ''}${sectionName}`, - '\x1b[0m', - '\n', - ) - lastTestSection[testName] = sectionName - lastTestName = testName - } - if (message) { - console.log(message) - } -} - -export const viewFeature = async (index: number) => { - await click(byId(`feature-item-${index}`)) - await waitForElementVisible('#create-feature-modal') -} - -export const addSegmentOverrideConfig = async ( - index: number, - value: string | boolean | number, - selectionIndex = 0, -) => { - await click(byId('segment_overrides')) - await click(byId(`select-segment-option-${selectionIndex}`)) - - await waitForElementVisible(byId(`segment-override-value-${index}`)) - await setText(byId(`segment-override-value-${index}`), `${value}`) - await click(byId(`segment-override-toggle-${index}`)) -} - -export const addSegmentOverride = async ( - index: number, - value: string | boolean | number, - selectionIndex = 0, - mvs: MultiVariate[] = [], -) => { - await click(byId('segment_overrides')) - await click(byId(`select-segment-option-${selectionIndex}`)) - await waitForElementVisible(byId(`segment-override-value-${index}`)) - if (value) { - await click(`${byId(`segment-override-${index}`)} [role="switch"]`) - } - if (mvs) { - await Promise.all( - mvs.map(async (v, i) => { - await setText( - `.segment-overrides ${byId(`featureVariationWeight${v.value}`)}`, - `${v.weight}`, - ) - }), - ) - } -} - -export const saveFeature = async () => { - await click('#update-feature-btn') - await waitForElementVisible('.toast-message') - await waitForElementNotExist('.toast-message') - await closeModal() - await waitForElementNotExist('#create-feature-modal') -} - -export const saveFeatureSegments = async () => { - await click('#update-feature-segments-btn') - await waitForElementVisible('.toast-message') - await waitForElementNotExist('.toast-message') - await closeModal() - await waitForElementNotExist('#create-feature-modal') -} - -export const createEnvironment = async (name: string) => { - await setText('[name="envName"]', name) - await click('#create-env-btn') - await waitForElementVisible( - byId(`switch-environment-${name.toLowerCase()}-active`), - ) -} - -export const goToUser = async (index: number) => { - await click('#features-link') - await click('#users-link') - await click(byId(`user-item-${index}`)) -} - -export const gotoFeature = async (index: number) => { - await click(byId(`feature-item-${index}`)) - await waitForElementVisible('#create-feature-modal') -} - -export const setSegmentOverrideIndex = async ( - index: number, - newIndex: number, -) => { - await click(byId('segment_overrides')) - await setText(byId(`sort-${index}`), `${newIndex}`) -} - -export const assertInputValue = (selector: string, v: string) => - t.expect(Selector(selector).value).eql(v) -export const assertTextContent = (selector: string, v: string) => - t.expect(Selector(selector).textContent).eql(v) -export const assertTextContentContains = (selector: string, v: string) => - t.expect(Selector(selector).textContent).contains(v) -export const getText = (selector: string) => Selector(selector).innerText - -export const parseTryItResults = async (): Promise> => { - const text = await getText('#try-it-results') - try { - return JSON.parse(text) - } catch (e) { - throw new Error('Try it results are not valid JSON') - } -} - -export const cloneSegment = async (index: number, name: string) => { - await click(byId(`segment-action-${index}`)) - await click(byId(`segment-clone-${index}`)) - await setText('[name="clone-segment-name"]', name) - await click('#confirm-clone-segment-btn') - await waitForElementVisible(byId(`segment-${index + 1}-name`)) -} - -export const deleteSegmentFromPage = async (name: string) => { - await click(byId(`remove-segment-btn`)) - await setText('[name="confirm-segment-name"]', name) - await click('#confirm-remove-segment-btn') - await waitForElementVisible(byId('show-create-segment-btn')) -} -export const deleteSegment = async (index: number, name: string) => { - await click(byId(`segment-action-${index}`)) - await click(byId(`segment-remove-${index}`)) - await setText('[name="confirm-segment-name"]', name) - await click('#confirm-remove-segment-btn') - await waitForElementNotExist(`remove-segment-btn-${index}`) -} - -export const login = async (email: string, password: string) => { - await setText('[name="email"]', `${email}`) - await setText('[name="password"]', `${password}`) - await click('#login-btn') - await waitForElementVisible('#project-manage-widget') -} -export const logout = async () => { - await click('#account-settings-link') - await click('#logout-link') - await waitForElementVisible('#login-page') - await t.wait(500) -} - -export const goToFeatureVersions = async (featureIndex: number) => { - await gotoFeature(featureIndex) - if (await isElementExists('change-history')) { - await click(byId('change-history')) - } else { - await click(byId('tabs-overflow-button')) - await click(byId('change-history')) - } -} - -export const compareVersion = async ( - featureIndex: number, - versionIndex: number, - compareOption: 'LIVE' | 'PREVIOUS' | null, - oldEnabled: boolean, - newEnabled: boolean, - oldValue?: FlagsmithValue, - newValue?: FlagsmithValue, -) => { - await goToFeatureVersions(featureIndex) - await click(byId(`history-item-${versionIndex}-compare`)) - if (compareOption === 'LIVE') { - await click(byId(`history-item-${versionIndex}-compare-live`)) - } else if (compareOption === 'PREVIOUS') { - await click(byId(`history-item-${versionIndex}-compare-previous`)) - } - - await assertTextContent(byId(`old-enabled`), `${oldEnabled}`) - await assertTextContent(byId(`new-enabled`), `${newEnabled}`) - if (oldValue) { - await assertTextContent(byId(`old-value`), `${oldValue}`) - } - if (newValue) { - await assertTextContent(byId(`old-value`), `${oldValue}`) - } - await closeModal() -} -export const assertNumberOfVersions = async ( - index: number, - versions: number, -) => { - await goToFeatureVersions(index) - await waitForElementVisible(byId(`history-item-${versions - 2}-compare`)) - await closeModal() -} - -export const createRemoteConfig = async ( - index: number, - name: string, - value: string | number | boolean, - description = 'description', - defaultOff?: boolean, - mvs: MultiVariate[] = [], -) => { - const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}` - await gotoFeatures() - await click('#show-create-feature-btn') - await setText(byId('featureID'), name) - await setText(byId('featureValue'), `${value}`) - await setText(byId('featureDesc'), description) - if (!defaultOff) { - await click(byId('toggle-feature-button')) - } - await Promise.all( - mvs.map(async (v, i) => { - await click(byId('add-variation')) - - await setText(byId(`featureVariationValue${i}`), v.value) - await setText(byId(`featureVariationWeight${v.value}`), `${v.weight}`) - }), - ) - await click(byId('create-feature-btn')) - await waitForElementVisible(byId(`feature-value-${index}`)) - await assertTextContent(byId(`feature-value-${index}`), expectedValue) - await closeModal() -} - -export const createOrganisationAndProject = async ( - organisationName: string, - projectName: string, -) => { - log('Create Organisation') - await click(byId('home-link')) - await click(byId('create-organisation-btn')) - await setText('[name="orgName"]', organisationName) - await click('#create-org-btn') - await waitForElementVisible(byId('project-manage-widget')) - - log('Create Project') - await click('.btn-project-create') - await setText(byId('projectName'), projectName) - await click(byId('create-project-btn')) - await waitForElementVisible(byId('features-page')) -} -export const editRemoteConfig = async ( - index: number, - value: string | number | boolean, - toggleFeature = false, - mvs: MultiVariate[] = [], -) => { - const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}` - await gotoFeatures() - - await click(byId(`feature-item-${index}`)) - await setText(byId('featureValue'), `${value}`) - if (toggleFeature) { - await click(byId('toggle-feature-button')) - } - await Promise.all( - mvs.map(async (v, i) => { - await setText(byId(`featureVariationWeight${v.value}`), `${v.weight}`) - }), - ) - await click(byId('update-feature-btn')) - if (value) { - await waitForElementVisible(byId(`feature-value-${index}`)) - await assertTextContent(byId(`feature-value-${index}`), expectedValue) - } - await closeModal() -} -export const closeModal = async () => { - log('Close Modal') - await t.click('body', { - offsetX: 50, - offsetY: 50, - }) -} -export const createFeature = async ( - index: number, - name: string, - value?: string | boolean | number, - description = 'description', -) => { - await gotoFeatures() - await click('#show-create-feature-btn') - await setText(byId('featureID'), name) - await setText(byId('featureDesc'), description) - if (value) { - await click(byId('toggle-feature-button')) - } - await click(byId('create-feature-btn')) - await waitForElementVisible(byId(`feature-item-${index}`)) - await closeModal() -} - -export const deleteFeature = async (index: number, name: string) => { - await click(byId(`feature-action-${index}`)) - await waitForElementVisible(byId(`feature-remove-${index}`)) - await click(byId(`feature-remove-${index}`)) - await setText('[name="confirm-feature-name"]', name) - await click('#confirm-remove-feature-btn') - await waitForElementNotExist(`feature-remove-${index}`) -} - -export const toggleFeature = async (index: number, toValue: boolean) => { - await click(byId(`feature-switch-${index}-${toValue ? 'off' : 'on'}`)) - await click('#confirm-toggle-feature-btn') - await waitForElementVisible( - byId(`feature-switch-${index}-${toValue ? 'on' : 'off'}`), - ) -} - -export const setUserPermissions = async (index: number, toValue: boolean) => { - await click(byId(`feature-switch-${index}${toValue ? '-off' : 'on'}`)) - await click('#confirm-toggle-feature-btn') - await waitForElementVisible( - byId(`feature-switch-${index}${toValue ? '-on' : 'off'}`), - ) -} - -export const setSegmentRule = async ( - ruleIndex: number, - orIndex: number, - name: string, - operator: string, - value: string | number | boolean, -) => { - await setText(byId(`rule-${ruleIndex}-property-${orIndex}`), name) - if (operator) { - await setText(byId(`rule-${ruleIndex}-operator-${orIndex}`), operator) - } - await setText(byId(`rule-${ruleIndex}-value-${orIndex}`), `${value}`) -} - -export const createSegment = async ( - index: number, - id: string, - rules?: Rule[], -) => { - await click(byId('show-create-segment-btn')) - await setText(byId('segmentID'), id) - for (let x = 0; x < rules.length; x++) { - const rule = rules[x] - if (x > 0) { - // eslint-disable-next-line no-await-in-loop - await click(byId('add-rule')) - } - // eslint-disable-next-line no-await-in-loop - await setSegmentRule(x, 0, rule.name, rule.operator, rule.value) - if (rule.ors) { - for (let orIndex = 0; orIndex < rule.ors.length; orIndex++) { - const or = rule.ors[orIndex] - // eslint-disable-next-line no-await-in-loop - await click(byId(`rule-${x}-or`)) - // eslint-disable-next-line no-await-in-loop - await setSegmentRule(x, orIndex + 1, or.name, or.operator, or.value) - } - } - } - - // Create - await click(byId('create-segment')) - await waitForElementVisible(byId(`segment-${index}-name`)) - await assertTextContent(byId(`segment-${index}-name`), id) -} - -export const waitAndRefresh = async (waitFor = 3000) => { - logUsingLastSection(`Waiting for ${waitFor}ms, then refreshing.`) - await t.wait(waitFor) - await t.eval(() => location.reload()) -} - -export const refreshUntilElementVisible = async ( - selector: string, - maxRetries = 20, -) => { - const element = Selector(selector) - const isElementVisible = async () => - (await element.exists) && (await element.visible) - let retries = 0 - while (retries < maxRetries && !(await isElementVisible())) { - await t.eval(() => location.reload()) // Reload the page - await t.wait(3000) - retries++ - } - return t.scrollIntoView(element) -} - -const permissionsMap = { - 'APPROVE_CHANGE_REQUEST': 'environment', - 'CREATE_CHANGE_REQUEST': 'environment', - 'CREATE_ENVIRONMENT': 'project', - 'CREATE_FEATURE': 'project', - 'CREATE_PROJECT': 'organisation', - 'DELETE_FEATURE': 'project', - 'MANAGE_IDENTITIES': 'environment', - 'MANAGE_SEGMENTS': 'project', - 'MANAGE_SEGMENT_OVERRIDES': 'environment', - 'MANAGE_TAGS': 'project', - 'MANAGE_USERS': 'organisation', - 'MANAGE_USER_GROUPS': 'organisation', - 'UPDATE_FEATURE_STATE': 'environment', - 'VIEW_AUDIT_LOG': 'project', - 'VIEW_ENVIRONMENT': 'environment', - 'VIEW_IDENTITIES': 'environment', - 'VIEW_PROJECT': 'project', -} as const - -export const setUserPermission = async ( - email: string, - permission: keyof typeof permissionsMap | 'ADMIN', - entityName: string | null, - entityLevel?: 'project' | 'environment' | 'organisation', - parentName?: string, -) => { - await click(byId('users-and-permissions')) - await click(byId(`user-${email}`)) - const level = permissionsMap[permission] || entityLevel - await click(byId(`${level}-permissions-tab`)) - if (parentName) { - await clickByText(parentName, 'a') - } - if (entityName) { - await click(byId(`permissions-${entityName.toLowerCase()}`)) - } - if (permission === 'ADMIN') { - await click(byId(`admin-switch-${level}`)) - } else { - await click(byId(`permission-switch-${permission}`)) - } - await closeModal() -} - -export default {} diff --git a/frontend/e2e/helpers.playwright.ts b/frontend/e2e/helpers.playwright.ts new file mode 100644 index 000000000000..f77692718b35 --- /dev/null +++ b/frontend/e2e/helpers.playwright.ts @@ -0,0 +1,1501 @@ +import { Page, expect, Locator } from '@playwright/test'; +import Project from '../common/project'; +import fetch from 'node-fetch'; +import flagsmith from 'flagsmith/isomorphic'; +import { IFlagsmith } from 'flagsmith/types'; + +export const LONG_TIMEOUT = 20000; + +// Browser debugging - console and network logging +export const setupBrowserLogging = (page: Page) => { + // Track console messages + page.on('console', async (msg) => { + const type = msg.type(); + const text = msg.text(); + + // Only log errors and warnings + if (type === 'error') { + console.error('\n🔴 [CONSOLE ERROR]', text); + // Try to get stack trace if available + const args = msg.args(); + for (const arg of args) { + try { + const val = await arg.jsonValue(); + if (val && typeof val === 'object' && val.stack) { + console.error(' Stack:', val.stack); + } + } catch (e) { + // Ignore if we can't get the value + } + } + } else if (type === 'warning') { + // Disabled to reduce noise + // console.warn('\n🟡 [CONSOLE WARNING]', text); + } + }); + + // Track page errors + page.on('pageerror', (error) => { + console.error('\n🔴 [PAGE ERROR]', error.message); + if (error.stack) { + console.error(' Stack:', error.stack); + } + }); + + // Track failed network requests + page.on('requestfailed', (request) => { + const url = request.url(); + const failure = request.failure(); + console.error('\n🔴 [NETWORK FAILED]', request.method(), url); + if (failure) { + console.error(' Error:', failure.errorText); + } + }); + + // Track API responses with errors + page.on('response', async (response) => { + const url = response.url(); + const status = response.status(); + + // Only log API calls (not static assets) + if (!url.includes('/api/') && !url.includes('/e2etests/')) { + return; + } + + // Ignore false positive errors that are expected/harmless + if (status === 404 && url.includes('/usage-data/')) { + // usage-data 404s are expected for new orgs without billing + return; + } + if (status === 404 && (url.includes('/list-change-requests/') || url.includes('/change-requests/'))) { + // Change requests is an enterprise feature, 404s are expected in OSS + return; + } + if (status === 404 && url.includes('/release-pipelines/')) { + // Release pipelines is an enterprise feature, 404s are expected in OSS + return; + } + if (status === 404 && url.includes('/roles/')) { + // Roles endpoint may not exist in certain configurations + return; + } + if (status === 404 && url.includes('/saml/configuration/')) { + // SAML is an enterprise feature, 404s are expected in OSS + return; + } + if (status === 404 && url.includes('/segments/undefined/')) { + // Ignore undefined segment lookups (happens during initial load) + return; + } + if (status === 400 && url.includes('/organisations/undefined/')) { + // Happens during initial page load before org is selected + return; + } + if (status === 403) { + // These 403s are expected for non-admin users + if (url.includes('/get-subscription-metadata/') || + url.includes('/usage-data/') || + url.includes('/invite-links/') || + url.includes('/roles/') || + url.includes('/change-requests/') || + url.includes('/api-keys/') || + url.includes('/metrics/') || + url.includes('/invites/')) { + return; + } + } + if (status === 429 && url.includes('/usage-data/')) { + // Usage data endpoint has rate limiting, throttling is expected + return; + } + + // Log throttling, rate limiting, and server errors + if (status === 429) { + console.error('\n🔴 [API THROTTLED]', response.request().method(), url); + try { + const body = await response.text(); + console.error(' Response:', body); + } catch (e) { + // Ignore if we can't read the body + } + } else if (status >= 400 && status < 500) { + console.error(`\n🔴 [API CLIENT ERROR ${status}]`, response.request().method(), url); + try { + const body = await response.text(); + console.error(' Response:', body); + } catch (e) { + // Ignore if we can't read the body + } + } else if (status >= 500) { + console.error(`\n🔴 [API SERVER ERROR ${status}]`, response.request().method(), url); + try { + const body = await response.text(); + console.error(' Response:', body); + } catch (e) { + // Ignore if we can't read the body + } + } + }); + + console.log('✅ Browser logging enabled (console errors, network failures, API errors)'); +}; + +export const byId = (id: string) => `[data-test="${id}"]`; + +export type MultiVariate = { value: string; weight: number }; + +export type Rule = { + name: string; + operator: string; + value: string | number | boolean; + ors?: Rule[]; +}; + +// Initialize Flagsmith once +const initProm = flagsmith.init({ + api: Project.flagsmithClientAPI, + environmentID: Project.flagsmith, + fetch, +}); + +export const getFlagsmith = async function (): Promise { + await initProm; + return flagsmith as IFlagsmith; +}; + +// Logging functions +let currentSection = ''; + +export const log = (section?: string, message?: string) => { + if (section) { + currentSection = section; + console.log(`\n[${section}]`); + } + if (message) { + console.log(message); + } +}; + +export const logUsingLastSection = (message: string) => { + if (currentSection) { + console.log(`[${currentSection}] ${message}`); + } else { + console.log(message); + } +}; + +// API Request logger for Playwright +export const checkApiRequest = ( + urlPattern: RegExp, + method: 'get' | 'post' | 'put' | 'patch' | 'delete', +) => { + const requests: any[] = []; + + return { + requests, + clear: () => { + requests.length = 0; + }, + }; +}; + +// Page-based helper functions +export class E2EHelpers { + constructor(private page: Page) {} + + async isElementExists(selector: string): Promise { + return await this.page.locator(byId(selector)).count() > 0; + } + + async setText(selector: string, text: string) { + logUsingLastSection(`Set text ${selector} : ${text}`); + const element = this.page.locator(selector).first(); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await element.clear(); + if (text) { + await element.fill(text); + } + } + + async waitForElementVisible(selector: string, timeout: number = LONG_TIMEOUT) { + logUsingLastSection(`Waiting element visible ${selector}`); + await this.page.locator(selector).first().waitFor({ + state: 'visible', + timeout + }); + } + + async waitForElementNotClickable(selector: string) { + logUsingLastSection(`Waiting element not clickable ${selector}`); + const element = this.page.locator(selector).first(); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await expect(element).toBeDisabled(); + } + + async waitForElementClickable(selector: string) { + logUsingLastSection(`Waiting element clickable ${selector}`); + const element = this.page.locator(selector).first(); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await expect(element).toBeEnabled(); + } + + async clickSegmentByName(name: string) { + const selector = '[data-test^="segment-"][data-test$="-name"]'; + const element = this.page.locator(selector).filter({ hasText: name }); + await element.scrollIntoViewIfNeeded(); + await expect(element).toBeVisible({ timeout: LONG_TIMEOUT }); + await element.click(); + } + + async navigateToSegment(name: string) { + const segmentList = this.page.locator('#segment-list'); + const segmentElement = segmentList.locator('[data-test^="segment-"][data-test$="-name"]').filter({ hasText: name }).first(); + await segmentElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await segmentElement.scrollIntoViewIfNeeded(); + await segmentElement.click(); + } + + async waitForElementNotExist(selector: string) { + logUsingLastSection(`Waiting element not exist ${selector}`); + await expect(this.page.locator(selector)).toHaveCount(0, { timeout: 10000 }); + } + + async waitForNetworkIdle() { + await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { + // Silently continue if timeout - page might already be idle + }); + } + + async waitForDomContentLoaded() { + await this.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => { + // Silently continue if timeout - DOM might already be loaded + }); + } + + async waitForPageFullyLoaded() { + await this.waitForDomContentLoaded(); + await this.waitForNetworkIdle(); + } + + async getInputValue(selector: string): Promise { + const element = this.page.locator(selector).first(); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + return await element.inputValue(); + } + + async scrollBy(x: number, y: number) { + await this.page.evaluate(({ x, y }) => { + window.scrollBy(x, y); + }, { x, y }); + } + + async scrollToBottom() { + await this.page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); + }); + } + + async gotoFeatures() { + await this.click('#features-link'); + await this.waitForElementVisible('#show-create-feature-btn'); + await this.waitForPageFullyLoaded(); + } + + async click(selector: string) { + await this.waitForElementVisible(selector); + const element = this.page.locator(selector).first(); + await element.scrollIntoViewIfNeeded(); + await expect(element).toBeEnabled({ timeout: LONG_TIMEOUT }); + await element.hover(); + await element.click(); + } + + async clickByText(text: string, element: string = 'button') { + logUsingLastSection(`Click by text ${text} ${element}`); + const selector = this.page.locator(element).filter({ hasText: text }); + await selector.scrollIntoViewIfNeeded(); + await expect(selector).toBeEnabled({ timeout: 5000 }); + await selector.hover(); + await selector.click(); + } + + async gotoSegments() { + await this.click('#segments-link'); + await this.waitForElementVisible(byId('show-create-segment-btn')); + } + + async getFeatureValue(name: string, projectId: string): Promise { + logUsingLastSection('Getting feature value for ' + name); + const url = `${Project.api}projects/${projectId}/features/?page_size=999&search=${name}&environment=`; + const token = (await this.getCookie('.Admin.Token')) || ''; + const options = { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'x-e2e-token': process.env.E2E_TEST_TOKEN || '', + ...(token && { Authorization: `Token ${token}` }), + }, + method: 'GET', + }; + const res = await fetch(url, options); + const json = await res.json(); + const feature = json.results.find( + (v: any) => v.name.toLowerCase() === name.toLowerCase(), + ); + return feature?.initial_value; + } + + async getCookie(name: string): Promise { + const cookies = await this.page.context().cookies(); + const cookie = cookies.find(c => c.name === name); + return cookie?.value; + } + + async setCookie(name: string, value: string, domain = 'localhost') { + await this.page.context().addCookies([{ + name, + value, + domain, + path: '/', + }]); + } + + async login( + email: string = process.env.E2E_USER || '', + password: string = process.env.E2E_PASS || '', + ) { + await this.page.goto('/login'); + // Wait for both fields to be visible + await this.waitForElementVisible('[name="email"]'); + await this.waitForElementVisible('[name="password"]'); + // Small delay to let any autofocus logic complete + await this.page.waitForTimeout(500); + // Now fill the fields - fill() handles focus internally + await this.setText('[name="email"]', email); + await this.setText('[name="password"]', password); + await this.click('#login-btn'); + // Wait for navigation to complete - either to an organization or create page + await this.page.waitForURL((url) => { + return url.pathname.includes('/organisation/') || url.pathname.includes('/create'); + }, { timeout: LONG_TIMEOUT }); + + // Check if we're on the create page (no organizations) + const currentUrl = this.page.url(); + if (currentUrl.includes('/create')) { + // User has no organizations, we're on the create page + log('User has no organizations, on create page'); + } else { + // Wait for the project manage widget to be present and projects to load + await this.waitForElementVisible('#project-manage-widget'); + } + // Wait for loading to complete - either project list or no projects message appears + await this.page.waitForFunction(() => { + const widget = document.querySelector('#project-manage-widget'); + if (!widget) return true; // If no widget, we're on create page - that's ok + // Check if loader is gone and content is visible + const hasLoader = widget.querySelector('.centered-container .loader'); + return !hasLoader; + }, { timeout: LONG_TIMEOUT }); + } + + async logout() { + try { + await this.click('#account-settings-link'); + await this.click('#logout-link'); + await this.waitForElementVisible('#login-page'); + await this.page.waitForTimeout(500); + } catch (e) { + console.log('Could not log out:', e); + } + } + + // Additional helper methods for common operations + async selectToggle(selector: string, value: boolean = true) { + const element = this.page.locator(selector).first(); + await element.waitFor({ state: 'visible' }); + const isChecked = await element.isChecked(); + if (isChecked !== value) { + await element.click(); + } + } + + async waitForUrl(urlPattern: string | RegExp, timeout: number = LONG_TIMEOUT) { + await this.page.waitForURL(urlPattern, { timeout }); + } + + async takeScreenshot(name: string) { + await this.page.screenshot({ path: `test-results/screenshots/${name}.png` }); + } + + async waitAndClick(selector: string) { + await this.waitForElementVisible(selector); + await this.click(selector); + } + + async assertTextContent(selector: string, expectedText: string) { + await expect(this.page.locator(selector)).toContainText(expectedText); + } + + async assertElementExists(selector: string) { + await expect(this.page.locator(selector)).toHaveCount(1); + } + + async assertElementNotExists(selector: string) { + await expect(this.page.locator(selector)).toHaveCount(0); + } + + async waitForApiResponse(urlPattern: string | RegExp) { + return this.page.waitForResponse(urlPattern); + } + + // Console and error logging + async getConsoleMessages() { + const messages: string[] = []; + this.page.on('console', msg => { + if (msg.type() === 'error') { + messages.push(msg.text()); + } + }); + return messages; + } + + // Add client script for error logging + async addErrorLogging() { + await this.page.addInitScript(() => { + window.addEventListener('error', (e) => { + console.error('Page error:', e.message, e.filename, e.lineno, e.colno); + }); + window.addEventListener('unhandledrejection', (e) => { + console.error('Unhandled promise rejection:', e.reason); + }); + }); + } +} + +// Export a factory function to create helpers for a page +export function createHelpers(page: Page): E2EHelpers { + return new E2EHelpers(page); +} + +// ============================================================================ +// Standalone helper functions +// ============================================================================ + +// Check if an element exists +export const isElementExists = async (page: Page, selector: string): Promise => { + return await page.locator(byId(selector)).count() > 0; +}; + +// Set text in an input field +export const setText = async (page: Page, selector: string, text: string) => { + logUsingLastSection(`Set text ${selector} : ${text}`); + const element = page.locator(selector); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + // Use Playwright's built-in fill() which handles focus automatically + // fill() will: wait for actionability, scroll into view, focus, and fill + await element.fill(''); + if (text) { + await element.fill(text); + } +}; + +// Wait for an element to be visible +export const waitForElementVisible = async (page: Page, selector: string, timeout: number = LONG_TIMEOUT) => { + logUsingLastSection(`Waiting element visible ${selector}`); + await page.locator(selector).first().waitFor({ + state: 'visible', + timeout + }); +}; + +// Wait for an element to be focused (useful for autofocus scenarios) +export const waitForElementFocused = async (page: Page, selector: string, timeout: number = 5000) => { + logUsingLastSection(`Waiting for element to be focused ${selector}`); + + // First ensure the element exists and is visible + await page.locator(selector).first().waitFor({ state: 'visible', timeout }); + + // Then wait for it to be focused + await page.waitForFunction((sel) => { + const element = document.querySelector(sel); + return element === document.activeElement; + }, selector, { timeout }); +}; + +// Wait for an element to not be clickable (disabled) +export const waitForElementNotClickable = async (page: Page, selector: string) => { + logUsingLastSection(`Waiting element not clickable ${selector}`); + const element = page.locator(selector).first(); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await expect(element).toBeDisabled({ timeout: LONG_TIMEOUT }); +}; + +// Wait for an element to be clickable (enabled) +export const waitForElementClickable = async (page: Page, selector: string) => { + logUsingLastSection(`Waiting element clickable ${selector}`); + const element = page.locator(selector).first(); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await expect(element).toBeEnabled({ timeout: LONG_TIMEOUT }); +}; + +// Click a segment by name +export const clickSegmentByName = async (page: Page, name: string) => { + const selector = '[data-test^="segment-"][data-test$="-name"]'; + const element = page.locator(selector).filter({ hasText: name }); + await element.scrollIntoViewIfNeeded(); + await expect(element).toBeVisible({ timeout: LONG_TIMEOUT }); + await element.click(); +}; + +// Navigate to a segment by name within segment-list +export const navigateToSegment = async (page: Page, name: string) => { + const segmentList = page.locator('#segment-list'); + const segmentElement = segmentList.locator('[data-test^="segment-"][data-test$="-name"]').filter({ hasText: name }).first(); + await segmentElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await segmentElement.scrollIntoViewIfNeeded(); + await segmentElement.click(); +}; + +// Wait for an element to not exist +export const waitForElementNotExist = async (page: Page, selector: string) => { + logUsingLastSection(`Waiting element not exist ${selector}`); + await expect(page.locator(selector)).toHaveCount(0, { timeout: 10000 }); +}; + +// Wait for network to be idle +export const waitForNetworkIdle = async (page: Page) => { + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { + // Silently continue if timeout - page might already be idle + }); +}; + +// Wait for DOM content to be loaded +export const waitForDomContentLoaded = async (page: Page) => { + await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => { + // Silently continue if timeout - DOM might already be loaded + }); +}; + +// Wait for page to be fully loaded (DOM + network) +export const waitForPageFullyLoaded = async (page: Page) => { + await waitForDomContentLoaded(page); + await waitForNetworkIdle(page); +}; + +// Get input value from an element +export const getInputValue = async (page: Page, selector: string): Promise => { + const element = page.locator(selector).first(); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + return await element.inputValue(); +}; + +// Scroll the page by x and y pixels +export const scrollBy = async (page: Page, x: number, y: number) => { + await page.evaluate(({ x, y }) => { + window.scrollBy(x, y); + }, { x, y }); +}; + +// Scroll to bottom of page +export const scrollToBottom = async (page: Page) => { + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); + }); +}; + +// Navigate to features page +export const gotoFeatures = async (page: Page) => { + await click(page, '#features-link'); + await waitForElementVisible(page, '#show-create-feature-btn'); + await waitForPageFullyLoaded(page); +}; + +// Click an element +export const click = async (page: Page, selector: string) => { + await waitForElementVisible(page, selector); + const element = page.locator(selector).first(); + // Wait for element to be attached before scrolling to avoid "not attached to DOM" errors + await element.waitFor({ state: 'attached', timeout: LONG_TIMEOUT }); + await element.scrollIntoViewIfNeeded(); + await expect(element).toBeEnabled({ timeout: LONG_TIMEOUT }); + await element.hover(); + await element.click(); +}; + +// Click by text content +export const clickByText = async (page: Page, text: string, element: string = 'button') => { + logUsingLastSection(`Click by text ${text} ${element}`); + const selector = page.locator(element).filter({ hasText: text }); + await selector.scrollIntoViewIfNeeded(); + await expect(selector).toBeEnabled({ timeout: 5000 }); + await selector.hover(); + await selector.click(); +}; + +// Navigate to segments page +export const gotoSegments = async (page: Page) => { + await click(page, '#segments-link'); + await waitForElementVisible(page, byId('show-create-segment-btn')); +}; + +// Create a role +export const createRole = async ( + page: Page, + roleName: string, + index: number, + users: number[], +) => { + await click(page, byId('tab-item-roles')); + await click(page, byId('create-role')); + await setText(page, byId('role-name'), roleName); + await click(page, byId('save-role')); + await closeModal(page); + await click(page, byId(`role-${index}`)); + await click(page, byId('members-tab')); + await click(page, byId('assigned-users')); + for (const userId of users) { + await click(page, byId(`assignees-list-item-${userId}`)); + } + await closeModal(page); +}; + +// Navigate to traits page +export const gotoTraits = async (page: Page) => { + await click(page, '#features-link'); + await click(page, '#users-link'); + await click(page, byId('user-item-0')); + await waitForElementVisible(page, '#add-trait'); +}; + +// Create a trait +export const createTrait = async ( + page: Page, + traitName: string, + value: string | boolean | number, +) => { + await click(page, '#add-trait'); + await waitForElementVisible(page, '#create-trait-modal'); + await setText(page, '[name="traitID"]', traitName); + await setText(page, '[name="traitValue"]', `${value}`); + await click(page, '#create-trait-btn'); + await page.waitForTimeout(2000); + await page.reload(); + + // Find the trait name element + const traitNameElement = page.locator('[class*="js-trait-key-"]').filter({ hasText: new RegExp(`^${traitName}$`) }); + await traitNameElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Get the index from the class to find the value element + const className = await traitNameElement.getAttribute('class'); + const index = className?.match(/js-trait-key-(\d+)/)?.[1]; + + const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}`; + await assertTextContent(page, byId(`user-trait-value-${index}`), expectedValue); +}; + +// Delete a trait +export const deleteTrait = async (page: Page, traitName: string) => { + // Find the trait by name using the js-trait-key class + const traitElement = page.locator('[class*="js-trait-key-"]').filter({ hasText: new RegExp(`^${traitName}$`) }); + await traitElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Get the index from the class attribute + const className = await traitElement.getAttribute('class'); + const index = className?.match(/js-trait-key-(\d+)/)?.[1]; + + await click(page, byId(`delete-user-trait-${index}`)); + await click(page, '#confirm-btn-yes'); + + // Wait for the trait name to disappear (not the index, as indices shift after deletion) + await page.waitForFunction((name) => { + const traitElements = document.querySelectorAll('[class*="js-trait-key-"]'); + for (const element of traitElements) { + if (element.textContent?.trim() === name) { + return false; + } + } + return true; + }, traitName, { timeout: LONG_TIMEOUT }); +}; + +// Get user feature value by feature name +export const getUserFeatureValue = async (page: Page, featureName: string): Promise => { + // Find the feature row by looking for the FeatureName component with the text + const featureRow = page.locator('[data-test^="user-feature-"]').filter({ + has: page.locator(`text="${featureName}"`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Find the value element within the feature row + const valueElement = featureRow.locator('[data-test^="user-feature-value-"]'); + return (await valueElement.textContent()) || ''; +}; + +// Assert user feature value by feature name +export const assertUserFeatureValue = async (page: Page, featureName: string, expectedValue: string) => { + // Find the feature row by looking for the FeatureName component with the text + const featureRow = page.locator('[data-test^="user-feature-"]').filter({ + has: page.locator(`text="${featureName}"`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Find the value element within the feature row + const valueElement = featureRow.locator('[data-test^="user-feature-value-"]'); + await expect(valueElement).toHaveText(expectedValue, { timeout: LONG_TIMEOUT }); +}; + +// Click on a user feature by name (to open the edit modal) +export const clickUserFeature = async (page: Page, featureName: string) => { + const featureRow = page.locator('[data-test^="user-feature-"]').filter({ + has: page.locator(`text="${featureName}"`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await featureRow.click(); +}; + +// Wait for user feature switch state by feature name +export const waitForUserFeatureSwitch = async (page: Page, featureName: string, state: 'on' | 'off') => { + const featureRow = page.locator('[data-test^="user-feature-"]').filter({ + has: page.locator(`text="${featureName}"`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Find the switch within the feature row with the specific state + const switchElement = featureRow.locator(`[data-test^="user-feature-switch-"][data-test$="-${state}"]`); + await switchElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); +}; + +// Click user feature switch by feature name +export const clickUserFeatureSwitch = async (page: Page, featureName: string, state: 'on' | 'off') => { + const featureRow = page.locator('[data-test^="user-feature-"]').filter({ + has: page.locator(`text="${featureName}"`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Find and click the switch within the feature row with the specific state + const switchElement = featureRow.locator(`[data-test^="user-feature-switch-"][data-test$="-${state}"]`); + await switchElement.click(); +}; + +// Wait for regular feature switch state by feature name (in features list) +export const waitForFeatureSwitch = async (page: Page, featureName: string, state: 'on' | 'off') => { + // Find feature by name in the features list + const featureRow = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("${featureName}")`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Find the switch within the feature row with the specific state + const switchElement = featureRow.locator(`[data-test^="feature-switch-"][data-test$="-${state}"]`); + await switchElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); +}; + +// Get feature index by name +export const getFeatureIndexByName = async (page: Page, featureName: string): Promise => { + const featureRow = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("${featureName}")`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + const dataTest = await featureRow.getAttribute('data-test'); + const index = dataTest?.match(/feature-item-(\d+)/)?.[1]; + + if (!index) { + throw new Error(`Could not find index for feature: ${featureName}`); + } + + return parseInt(index, 10); +}; + +// Click feature action button by feature name +export const clickFeatureAction = async (page: Page, featureName: string) => { + const index = await getFeatureIndexByName(page, featureName); + await click(page, byId(`feature-action-${index}`)); +}; + +// Wait for feature switch by name and state to be clickable or not clickable +export const waitForFeatureSwitchClickable = async (page: Page, featureName: string, state: 'on' | 'off', clickable: boolean = true) => { + // Find feature by name in the features list + const featureRow = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("${featureName}")`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Find the switch within the feature row with the specific state + const element = featureRow.locator(`[data-test^="feature-switch-"][data-test$="-${state}"]`).first(); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + if (clickable) { + await expect(element).toBeEnabled({ timeout: LONG_TIMEOUT }); + } else { + await expect(element).toBeDisabled({ timeout: LONG_TIMEOUT }); + } +}; + +// Add segment override configuration +export const addSegmentOverrideConfig = async ( + page: Page, + index: number, + value: string | boolean | number, + selectionIndex: number = 0, +) => { + // Check if dropdown is already visible, only click if it's not + const dropdownSelector = byId(`select-segment-option-${selectionIndex}`); + const isDropdownVisible = await page.locator(dropdownSelector).isVisible().catch(() => false); + + if (!isDropdownVisible) { + await click(page, byId('segment_overrides')); + } + + await click(page, dropdownSelector); + await waitForElementVisible(page, byId(`segment-override-value-${index}`)); + await setText(page, byId(`segment-override-value-${index}`), `${value}`); + await click(page, byId(`segment-override-toggle-${index}`)); +}; + +// Add segment override +export const addSegmentOverride = async ( + page: Page, + index: number, + value: string | boolean | number, + selectionIndex: number = 0, + mvs: MultiVariate[] = [], +) => { + // Check if dropdown is already visible, only click if it's not + const dropdownSelector = byId(`select-segment-option-${selectionIndex}`); + const isDropdownVisible = await page.locator(dropdownSelector).isVisible().catch(() => false); + + if (!isDropdownVisible) { + await click(page, byId('segment_overrides')); + } + + await click(page, dropdownSelector); + await waitForElementVisible(page, byId(`segment-override-value-${index}`)); + + // Set multivariate weights first before enabling the switch + if (mvs && mvs.length > 0) { + // Set weights sequentially instead of in parallel to avoid race conditions + for (const v of mvs) { + const weightSelector = `.segment-overrides ${byId(`featureVariationWeight${v.value}`)}`; + // Wait for the weight input to be visible before trying to set it + await waitForElementVisible(page, weightSelector); + await setText( + page, + weightSelector, + `${v.weight}`, + ); + // Small wait between each weight change + await page.waitForTimeout(100); + } + // Wait longer for React state to fully update after all weight changes + await page.waitForTimeout(500); + } + + // For boolean flags, simply click the toggle if the value is truthy + // This matches the TestCafe behavior exactly + if (value) { + await click(page, byId(`segment-override-toggle-${index}`)); + } +}; + +// Save feature +export const saveFeature = async (page: Page) => { + await click(page, '#update-feature-btn'); + await waitForElementVisible(page, '.toast-message'); + await waitForElementNotExist(page, '.toast-message'); + await closeModal(page); + await waitForElementNotExist(page, '#create-feature-modal'); +}; + +// Save feature segments +export const saveFeatureSegments = async (page: Page) => { + await click(page, '#update-feature-segments-btn'); + // Wait for success message to appear indicating save completed + await page.waitForSelector('.toast-message', { state: 'visible', timeout: 10000 }); + // Wait for toast to disappear + await page.waitForSelector('.toast-message', { state: 'hidden', timeout: 10000 }); + await closeModal(page); + await waitForElementNotExist(page, '#create-feature-modal'); +}; + +// Create an environment +export const createEnvironment = async (page: Page, name: string) => { + await setText(page, '[name="envName"]', name); + await click(page, '#create-env-btn'); + await waitForElementVisible( + page, + byId(`switch-environment-${name.toLowerCase()}-active`), + ); +}; + +// Navigate to a user +export const goToUser = async (page: Page, index: number) => { + await click(page, '#features-link'); + await click(page, '#users-link'); + await click(page, byId(`user-item-${index}`)); +}; + +// Navigate to a feature +export const gotoFeature = async (page: Page, featureName: string) => { + const featureRow = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`text="${featureName}"`) + }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await featureRow.click(); + await waitForElementVisible(page, '#create-feature-modal'); +}; + +// Set segment override index +export const setSegmentOverrideIndex = async ( + page: Page, + index: number, + newIndex: number, +) => { + await click(page, byId('segment_overrides')); + await setText(page, byId(`sort-${index}`), `${newIndex}`); +}; + +// Assert input value +export const assertInputValue = async (page: Page, selector: string, v: string) => { + await expect(page.locator(selector)).toHaveValue(v); +}; + +// Assert text content +export const assertTextContent = async (page: Page, selector: string, v: string) => { + await expect(page.locator(selector)).toHaveText(v); +}; + +// Assert text content contains +export const assertTextContentContains = async (page: Page, selector: string, v: string) => { + await expect(page.locator(selector)).toContainText(v); +}; + +// Get text from element +export const getText = async (page: Page, selector: string): Promise => { + return await page.locator(selector).innerText(); +}; + +// Parse try it results +export const parseTryItResults = async (page: Page): Promise> => { + const text = await getText(page, '#try-it-results'); + try { + return JSON.parse(text); + } catch (e) { + throw new Error('Try it results are not valid JSON'); + } +}; + +// Clone a segment +export const cloneSegment = async (page: Page, index: number, name: string) => { + await click(page, byId(`segment-action-${index}`)); + await click(page, byId(`segment-clone-${index}`)); + await setText(page, '[name="clone-segment-name"]', name); + await click(page, '#confirm-clone-segment-btn'); + await waitForElementVisible(page, byId(`segment-${index + 1}-name`)); +}; + +// Delete segment from the segment page +export const deleteSegmentFromPage = async (page: Page, name: string) => { + await click(page, byId('remove-segment-btn')); + await setText(page, '[name="confirm-segment-name"]', name); + await click(page, '#confirm-remove-segment-btn'); + await waitForElementVisible(page, byId('show-create-segment-btn')); +}; + +// Delete a segment +export const deleteSegment = async (page: Page, index: number, name: string) => { + await click(page, byId(`segment-action-${index}`)); + await click(page, byId(`segment-remove-${index}`)); + await setText(page, '[name="confirm-segment-name"]', name); + await click(page, '#confirm-remove-segment-btn'); + await waitForElementNotExist(page, `remove-segment-btn-${index}`); +}; + +// Login +export const login = async (page: Page, email: string, password: string) => { + await setText(page, '[name="email"]', email); + await setText(page, '[name="password"]', password); + await click(page, '#login-btn'); + // Wait for navigation to complete + await page.waitForURL(/\/organisation\/\d+/, { timeout: LONG_TIMEOUT }); + // Wait for the project manage widget to be present and projects to load + await waitForElementVisible(page, '#project-manage-widget'); + // Wait for loading to complete - either project list or no projects message appears + await page.waitForFunction(() => { + const widget = document.querySelector('#project-manage-widget'); + if (!widget) return false; + // Check if loader is gone and content is visible + const hasLoader = widget.querySelector('.centered-container .loader'); + return !hasLoader; + }, { timeout: LONG_TIMEOUT }); +}; + +// Logout +export const logout = async (page: Page) => { + await click(page, '#account-settings-link'); + await click(page, '#logout-link'); + await waitForElementVisible(page, '#login-page'); + await page.waitForTimeout(500); +}; + +// Navigate to feature versions +export const goToFeatureVersions = async (page: Page, featureName: string) => { + await gotoFeature(page, featureName); + if (await isElementExists(page, 'change-history')) { + await click(page, byId('change-history')); + } else { + await click(page, byId('tabs-overflow-button')); + await click(page, byId('change-history')); + } +}; + +// Compare version +export const compareVersion = async ( + page: Page, + featureName: string, + versionIndex: number, + compareOption: 'LIVE' | 'PREVIOUS' | null, + oldEnabled: boolean, + newEnabled: boolean, + oldValue?: any, + newValue?: any, +) => { + await goToFeatureVersions(page, featureName); + await click(page, byId(`history-item-${versionIndex}-compare`)); + if (compareOption === 'LIVE') { + await click(page, byId(`history-item-${versionIndex}-compare-live`)); + } else if (compareOption === 'PREVIOUS') { + await click(page, byId(`history-item-${versionIndex}-compare-previous`)); + } + + // Wait for comparison modal to fully load data + await page.waitForTimeout(2000); + + // Use .first() to handle cases where multiple comparison modals might exist in DOM + await expect(page.locator(byId('old-enabled')).first()).toHaveText(`${oldEnabled}`); + await expect(page.locator(byId('new-enabled')).first()).toHaveText(`${newEnabled}`); + if (oldValue !== undefined) { + // When value is null, the UI shows an empty string, not the text "null" + const expectedOldValue = oldValue === null ? '' : `${oldValue}`; + await expect(page.locator(byId('old-value')).first()).toHaveText(expectedOldValue); + } + if (newValue !== undefined) { + // When value is null, the UI shows an empty string, not the text "null" + const expectedNewValue = newValue === null ? '' : `${newValue}`; + await expect(page.locator(byId('new-value')).first()).toHaveText(expectedNewValue); + } + await closeModal(page); +}; + +// Assert number of versions +export const assertNumberOfVersions = async ( + page: Page, + featureName: string, + versions: number, +) => { + await goToFeatureVersions(page, featureName); + await waitForElementVisible(page, byId(`history-item-${versions - 2}-compare`)); + await closeModal(page); +}; + +// Create a remote config (feature with value) +export const createRemoteConfig = async ( + page: Page, + index: number, + name: string, + value: string | number | boolean, + description: string = 'description', + defaultOff?: boolean, + mvs: MultiVariate[] = [], +) => { + const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}`; + await gotoFeatures(page); + await click(page, '#show-create-feature-btn'); + await setText(page, byId('featureID'), name); + await setText(page, byId('featureValue'), `${value}`); + await setText(page, byId('featureDesc'), description); + if (!defaultOff) { + await click(page, byId('toggle-feature-button')); + } + for (let i = 0; i < mvs.length; i++) { + const v = mvs[i]; + await click(page, byId('add-variation')); + // Wait for the new variation row to appear + await page.waitForTimeout(200); + await setText(page, byId(`featureVariationValue${i}`), v.value); + await setText(page, byId(`featureVariationWeight${v.value}`), `${v.weight}`); + // Small wait between each variation to let React state update + await page.waitForTimeout(100); + } + // Wait for form validation to complete after all variations added + await page.waitForTimeout(500); + await click(page, byId('create-feature-btn')); + // Wait for the feature to be created and modal to close + await page.waitForTimeout(2000); // Increased wait for API to process + // Use longer timeout for multivariate flags which take longer to process + const timeout = mvs.length > 0 ? 45000 : 20000; + // Find the feature by name - look for the span containing the exact name text + const featureElement = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("${name}")`) + }).first(); + await featureElement.waitFor({ state: 'visible', timeout }); + const valueElement = featureElement.locator('[data-test^="feature-value-"]'); + await expect(valueElement).toHaveText(expectedValue, { timeout }); + await closeModal(page); + // Additional wait after feature creation to prevent auth token issues + await page.waitForTimeout(500); +}; + +// Create organisation and project +export const createOrganisationAndProject = async ( + page: Page, + organisationName: string, + projectName: string, +) => { + log('Create Organisation'); + await click(page, byId('home-link')); + await click(page, byId('create-organisation-btn')); + await setText(page, '[name="orgName"]', organisationName); + await click(page, '#create-org-btn'); + // Wait for navigation to projects page and project list to load + await page.waitForURL(/\/organisation\/\d+/, { timeout: LONG_TIMEOUT }); + await waitForElementVisible(page, byId('project-manage-widget')); + // Wait for loading to complete + await page.waitForFunction(() => { + const widget = document.querySelector('#project-manage-widget'); + if (!widget) return false; + const hasLoader = widget.querySelector('.centered-container .loader'); + return !hasLoader; + }, { timeout: LONG_TIMEOUT }); + + log('Create Project'); + await click(page, '.btn-project-create'); + await setText(page, byId('projectName'), projectName); + await click(page, byId('create-project-btn')); + await waitForElementVisible(page, byId('features-page')); +}; + +// Edit remote config +export const editRemoteConfig = async ( + page: Page, + featureName: string, + value: string | number | boolean, + toggleFeature: boolean = false, + mvs: MultiVariate[] = [], +) => { + const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}`; + await gotoFeatures(page); + + // Find and click the feature by name + const featureRow = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`text="${featureName}"`) + }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await featureRow.click(); + + // Change the value field first (if needed) to match TestCafe behavior + if (value !== '') { + await setText(page, byId('featureValue'), `${value}`); + } + + // Change multivariate weights - must be done after value field to avoid state conflicts + if (mvs.length > 0) { + // Wait after value field change before modifying weights + await page.waitForTimeout(500); + + for (const v of mvs) { + const selector = byId(`featureVariationWeight${v.value}`); + // Wait for the weight input to be visible before trying to interact with it + await waitForElementVisible(page, selector); + const input = page.locator(selector); + await input.clear(); + await page.waitForTimeout(100); + await input.fill(`${v.weight}`); + await page.waitForTimeout(100); + // Trigger blur to force validation + await input.blur(); + // Wait between each weight change to let React state and validation update + await page.waitForTimeout(500); + } + } + + if (toggleFeature) { + await click(page, byId('toggle-feature-button')); + } + + // Wait for the update button to become enabled after all changes + if (mvs.length > 0 || value !== '') { + // Use a long fixed timeout for form validation to complete + await page.waitForTimeout(1500); + } + + // Use button text to avoid strict mode violation (multiple buttons with same ID) + await click(page, 'button:has-text("Update Feature Value")'); + if (value) { + // Find the feature value element in the features list by name + const updatedFeatureRow = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`text="${featureName}"`) + }).first(); + const valueElement = updatedFeatureRow.locator('[data-test^="feature-value-"]'); + await expect(valueElement).toBeVisible({ timeout: LONG_TIMEOUT }); + await expect(valueElement).toHaveText(expectedValue, { timeout: LONG_TIMEOUT }); + } + await closeModal(page); +}; + +// Close modal +export const closeModal = async (page: Page) => { + log('Close Modal'); + await page.mouse.click(50, 50); + // Wait for modal to fully close by checking body no longer has modal-open class + await page.waitForFunction( + () => !document.body.classList.contains('modal-open'), + { timeout: LONG_TIMEOUT } + ); +}; + +// Create a feature (flag) +export const createFeature = async ( + page: Page, + index: number, + name: string, + value?: string | boolean | number, + description: string = 'description', +) => { + await gotoFeatures(page); + await click(page, '#show-create-feature-btn'); + await setText(page, byId('featureID'), name); + await setText(page, byId('featureDesc'), description); + if (value) { + await click(page, byId('toggle-feature-button')); + } + await page.waitForTimeout(500); + await click(page, byId('create-feature-btn')); + // Wait for feature creation to complete + await page.waitForTimeout(1500); + // Find the feature by name - look for the span containing the exact name text + const featureElement = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("${name}")`) + }).first(); + await featureElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await closeModal(page); + // Additional wait after feature creation to prevent auth token issues + await page.waitForTimeout(500); +}; + +// Delete a feature +export const deleteFeature = async (page: Page, name: string) => { + // Find the feature row by name + const featureRow = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("${name}")`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Get the index from the feature row's data-test attribute + const dataTest = await featureRow.getAttribute('data-test'); + const index = dataTest?.match(/feature-item-(\d+)/)?.[1]; + + if (!index) { + throw new Error(`Could not find index for feature: ${name}`); + } + + await click(page, byId(`feature-action-${index}`)); + await waitForElementVisible(page, byId(`feature-remove-${index}`)); + await click(page, byId(`feature-remove-${index}`)); + await setText(page, '[name="confirm-feature-name"]', name); + await click(page, '#confirm-remove-feature-btn'); + + // Wait for the feature to be removed from the list + await page.waitForFunction((featureName) => { + const features = document.querySelectorAll('[data-test^="feature-item-"]'); + for (const feature of features) { + const nameSpan = feature.querySelector('span'); + if (nameSpan && nameSpan.textContent === featureName) { + return false; + } + } + return true; + }, name, { timeout: LONG_TIMEOUT }); +}; + +// Toggle a feature on/off +export const toggleFeature = async (page: Page, name: string, toValue: boolean) => { + // Find the feature row by name + const featureRow = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("${name}")`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Find and click the switch within the feature row (click the opposite state to toggle) + const switchToClick = featureRow.locator(`[data-test^="feature-switch-"][data-test$="-${toValue ? 'off' : 'on'}"]`).first(); + await switchToClick.click(); + + await click(page, '#confirm-toggle-feature-btn'); + + // Wait for the new state to appear + const newSwitch = featureRow.locator(`[data-test^="feature-switch-"][data-test$="-${toValue ? 'on' : 'off'}"]`).first(); + await newSwitch.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); +}; + +// Set user permissions +export const setUserPermissions = async (page: Page, index: number, toValue: boolean) => { + await click(page, byId(`feature-switch-${index}${toValue ? '-off' : 'on'}`)); + await click(page, '#confirm-toggle-feature-btn'); + await waitForElementVisible( + page, + byId(`feature-switch-${index}${toValue ? '-on' : 'off'}`), + ); +}; + +// Set segment rule +export const setSegmentRule = async ( + page: Page, + ruleIndex: number, + orIndex: number, + name: string, + operator: string, + value: string | number | boolean, +) => { + await setText(page, byId(`rule-${ruleIndex}-property-${orIndex}`), name); + if (operator) { + await setText(page, byId(`rule-${ruleIndex}-operator-${orIndex}`), operator); + // Wait for the value field to be fully initialized after operator selection + await page.waitForTimeout(200); + } + // Ensure value field is visible and ready before filling + await waitForElementVisible(page, byId(`rule-${ruleIndex}-value-${orIndex}`)); + await setText(page, byId(`rule-${ruleIndex}-value-${orIndex}`), `${value}`); +}; + +// Create a segment +export const createSegment = async ( + page: Page, + index: number, + id: string, + rules?: Rule[], +) => { + await click(page, byId('show-create-segment-btn')); + await setText(page, byId('segmentID'), id); + if (rules && rules.length > 0) { + for (let x = 0; x < rules.length; x++) { + const rule = rules[x]; + if (x > 0) { + // eslint-disable-next-line no-await-in-loop + await click(page, byId('add-rule')); + // eslint-disable-next-line no-await-in-loop + // Wait for the new rule row to appear before trying to interact with it + await waitForElementVisible(page, byId(`rule-${x}-property-0`)); + } + // eslint-disable-next-line no-await-in-loop + await setSegmentRule(page, x, 0, rule.name, rule.operator, rule.value); + if (rule.ors) { + for (let orIndex = 0; orIndex < rule.ors.length; orIndex++) { + const or = rule.ors[orIndex]; + // eslint-disable-next-line no-await-in-loop + await click(page, byId(`rule-${x}-or`)); + // eslint-disable-next-line no-await-in-loop + // Wait for the new OR row to appear before trying to interact with it + await waitForElementVisible(page, byId(`rule-${x}-property-${orIndex + 1}`)); + // eslint-disable-next-line no-await-in-loop + await setSegmentRule(page, x, orIndex + 1, or.name, or.operator, or.value); + } + } + } + } + + // Create + await click(page, byId('create-segment')); + // Wait for segment to appear in the list by name (don't assume index due to sorting) + const selector = '[data-test^="segment-"][data-test$="-name"]'; + const element = page.locator(selector).filter({ hasText: id }); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); +}; + +// Wait and refresh +export const waitAndRefresh = async (page: Page, waitFor: number = 3000) => { + logUsingLastSection(`Waiting for ${waitFor}ms, then refreshing.`); + await page.waitForTimeout(waitFor); + await page.reload(); + // Wait for page to be fully loaded after refresh + await page.waitForLoadState('load'); + await page.waitForLoadState('networkidle'); + // Additional wait to ensure backend processing completes + await page.waitForTimeout(1000); +}; + +// Refresh until element is visible +export const refreshUntilElementVisible = async ( + page: Page, + selector: string, + maxRetries: number = 20, +) => { + const element = page.locator(selector); + let retries = 0; + while (retries < maxRetries) { + const isVisible = await element.isVisible().catch(() => false); + if (isVisible) { + break; + } + await page.reload(); + await page.waitForTimeout(3000); + retries++; + } + await element.scrollIntoViewIfNeeded(); +}; + +// Permission map +const permissionsMap = { + 'APPROVE_CHANGE_REQUEST': 'environment', + 'CREATE_CHANGE_REQUEST': 'environment', + 'CREATE_ENVIRONMENT': 'project', + 'CREATE_FEATURE': 'project', + 'CREATE_PROJECT': 'organisation', + 'DELETE_FEATURE': 'project', + 'MANAGE_IDENTITIES': 'environment', + 'MANAGE_SEGMENTS': 'project', + 'MANAGE_SEGMENT_OVERRIDES': 'environment', + 'MANAGE_TAGS': 'project', + 'MANAGE_USERS': 'organisation', + 'MANAGE_USER_GROUPS': 'organisation', + 'UPDATE_FEATURE_STATE': 'environment', + 'VIEW_AUDIT_LOG': 'project', + 'VIEW_ENVIRONMENT': 'environment', + 'VIEW_IDENTITIES': 'environment', + 'VIEW_PROJECT': 'project', +} as const; + +// Set user permission +export const setUserPermission = async ( + page: Page, + email: string, + permission: keyof typeof permissionsMap | 'ADMIN', + entityName: string | null, + entityLevel?: 'project' | 'environment' | 'organisation', + parentName?: string, +) => { + await click(page, byId('users-and-permissions')); + await click(page, byId(`user-${email}`)); + const level = permissionsMap[permission] || entityLevel; + await click(page, byId(`${level}-permissions-tab`)); + if (parentName) { + await clickByText(page, parentName, 'a'); + } + if (entityName) { + await click(page, byId(`permissions-${entityName.toLowerCase()}`)); + } + if (permission === 'ADMIN') { + await click(page, byId(`admin-switch-${level}`)); + } else { + await click(page, byId(`permission-switch-${permission}`)); + } + await closeModal(page); +}; diff --git a/frontend/e2e/index.cafe.js b/frontend/e2e/index.cafe.js deleted file mode 100644 index cc83f2fb479e..000000000000 --- a/frontend/e2e/index.cafe.js +++ /dev/null @@ -1,88 +0,0 @@ -const createTestCafe = require('testcafe'); -const fs = require('fs'); -const path = require('path'); -const { fork } = require('child_process'); -const _options = require("../.testcaferc") -const upload = require('../bin/upload-file'); -const minimist = require('minimist'); -const options = { - ..._options, - browsers: process.env.E2E_DEV ? ['firefox'] : ['firefox:headless'], - debugOnFail: !!process.env.E2E_DEV -} -let testcafe; -let server; -const dir = path.join(__dirname, '../reports/screen-captures'); -if (fs.existsSync(dir)) { - fs.rmdirSync(dir, { recursive: true }); -} - -const start = Date.now().valueOf(); -// Parse CLI arg --meta-filter -const args = minimist(process.argv.slice(2)); -const filterString = args['meta-filter']; // "type=smoke,priority=high" -const metaConditions = (filterString || '') - .split(',') - .map(pair => { - const [key, value] = pair.split('='); - return { key, value }; - }); -createTestCafe() - .then(async (tc) => { - testcafe = tc; - await new Promise((resolve) => { - process.env.PORT = 3000; - console.log(process.env.E2E_LOCAL) - if (process.env.E2E_LOCAL) { - resolve() - } else { - server = fork('./api/index'); - server.on('message', () => { - resolve(); - }); - } - }); - const runner = testcafe.createRunner() - const args = process.argv.splice(2).map(value => value.toLowerCase()); - console.log('Filter tests:', args) - const concurrentInstances = process.env.E2E_CONCURRENCY ?? 3 - console.log('E2E Concurrency:', concurrentInstances) - - return runner - .clientScripts('e2e/add-error-logs.js') - .src(['./e2e/init.cafe.js']) - .concurrency(parseInt(concurrentInstances)) - .filter((_, __, ___, testMeta, fixtureMeta) => { - const isEnterpriseRun = metaConditions.some(({ key, value }) => - key === 'category' && value === 'enterprise' - ) - if (isEnterpriseRun && testMeta.skipEnterprise) { - return false - } - return metaConditions.some(({ key, value }) => - testMeta[key] === value || fixtureMeta[key] === value - ) - }) - .run(options) - }) - .then(async (v) => { - // Upload files - console.log(`Test failures ${v} in ${Date.now().valueOf() - start}ms`); - if (fs.existsSync(dir) && !process.env.E2E_DEV) { - try { - const files = fs.readdirSync(dir); - await Promise.all(files.map(f => upload(path.join(dir, f)))); - } catch (e) { console.log('error uploading files', e); } - } else { - console.log('No files to upload'); - } - // Shut down server and testcafe - server.kill('SIGINT'); - testcafe.close(); - process.exit(v); - }) .catch(async (err) => { - console.error('TestCafe initialisation error:', err); - if (server) server.kill('SIGINT'); - if (testcafe) testcafe.close(); - process.exit(1); - }); diff --git a/frontend/e2e/init.cafe.js b/frontend/e2e/init.cafe.js deleted file mode 100644 index 24bab3e9b131..000000000000 --- a/frontend/e2e/init.cafe.js +++ /dev/null @@ -1,141 +0,0 @@ -import fetch from 'node-fetch' -import { test, fixture } from 'testcafe' -import { waitForReact } from 'testcafe-react-selectors' - -import Project from '../common/project' -import { getLogger, log, logout, logResults } from './helpers.cafe' -import environmentTest from './tests/environment-test' -import inviteTest from './tests/invite-test' -import projectTest from './tests/project-test' -import { testSegment1, testSegment2, testSegment3 } from './tests/segment-test' -import initialiseTests from './tests/initialise-tests' -import flagTests from './tests/flag-tests' -import versioningTests from './tests/versioning-tests' -import organisationPermissionTest from './tests/organisation-permission-test' -import projectPermissionTest from './tests/project-permission-test' -import environmentPermissionTest from './tests/environment-permission-test' -import flagsmith from 'flagsmith/isomorphic' -import rolesTest from './tests/roles-test' -import organisationTest from './tests/organisation-test' - -require('dotenv').config() - -const url = `http://localhost:${process.env.PORT || 8080}/` -const e2eTestApi = `${ - process.env.FLAGSMITH_API_URL || Project.api -}e2etests/teardown/` -const logger = getLogger() - -console.log( - '\n', - '\x1b[32m', - `E2E using API: ${e2eTestApi}. E2E URL: ${url}`, - '\x1b[0m', - '\n', -) - -fixture`E2E Tests`.requestHooks(logger).before(async () => { - const token = process.env.E2E_TEST_TOKEN - ? process.env.E2E_TEST_TOKEN - : process.env[`E2E_TEST_TOKEN_${Project.env.toUpperCase()}`] - await flagsmith.init({ - api: Project.flagsmithClientAPI, - environmentID: Project.flagsmith, - fetch, - }) - - if (token) { - await fetch(e2eTestApi, { - body: JSON.stringify({}), - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-E2E-Test-Auth-Token': token.trim(), - }, - method: 'POST', - }).then((res) => { - if (res.ok) { - // eslint-disable-next-line no-console - console.log( - '\n', - '\x1b[32m', - 'e2e teardown successful', - '\x1b[0m', - '\n', - ) - } else { - // eslint-disable-next-line no-console - console.error( - '\n', - '\x1b[31m', - 'e2e teardown failed', - res.status, - '\x1b[0m', - '\n', - ) - } - console.log('Starting E2E tests') - }) - } else { - // eslint-disable-next-line no-console - console.error( - '\n', - '\x1b[31m', - 'e2e teardown failed - no available token', - '\x1b[0m', - '\n', - ) - } -}).page`${url}` - .beforeEach(async () => { - await waitForReact() - }) - .afterEach(async (t) => { - if (t.test.meta.autoLogout) { - log('Log out') - await logout() - } - await logResults(logger.requests, t) - }) - -test('Segment-part-1', async () => await testSegment1(flagsmith)).meta({ - category: 'oss', -}) - -test('Segment-part-2', testSegment2).meta({ autoLogout: true, category: 'oss' }) - -test('Segment-part-3', testSegment3).meta({ autoLogout: true, category: 'oss' }) - -test('Flag', flagTests).meta({ autoLogout: true, category: 'oss' }) - -test('Signup', initialiseTests).meta({ autoLogout: true, category: 'oss' }) - -test('Invite', inviteTest).meta({ category: 'oss' }) - -test('Environment', environmentTest).meta({ autoLogout: true, category: 'oss' }) - -test('Project', projectTest).meta({ autoLogout: true, category: 'oss' }) - -test('Organization', organisationTest).meta({ - autoLogout: true, - category: 'oss', -}) - -test('Versioning', versioningTests).meta({ autoLogout: true, category: 'oss' }) - -test('Organisation-permission', organisationPermissionTest).meta({ - autoLogout: true, - category: 'enterprise', -}) - -test('Project-permission', projectPermissionTest).meta({ - autoLogout: true, - category: 'enterprise', -}) - -test('Environment-permission', environmentPermissionTest).meta({ - autoLogout: true, - category: 'enterprise', -}) - -test('Roles', rolesTest).meta({ autoLogout: true, category: 'enterprise' }) diff --git a/frontend/e2e/run-with-retry.ts b/frontend/e2e/run-with-retry.ts new file mode 100644 index 000000000000..cc029c668b73 --- /dev/null +++ b/frontend/e2e/run-with-retry.ts @@ -0,0 +1,108 @@ +import { execSync } from 'child_process'; +import { runTeardown } from './teardown'; + +require('dotenv').config(); + +const RETRIES = parseInt(process.env.E2E_RETRIES || '1', 10); + +function runPlaywright(args: string[], quietMode: boolean, isRetry: boolean): boolean { + try { + // Quote arguments that contain spaces or special shell characters + const quotedArgs = args.map(arg => { + if (arg.includes(' ') || arg.includes('|') || arg.includes('&') || arg.includes(';')) { + return `"${arg}"`; + } + return arg; + }); + const playwrightCmd = ['npx', 'cross-env', 'NODE_ENV=production', 'E2E=true']; + + // Skip cleanup on retries to preserve failed.json and test artifacts + if (isRetry) { + playwrightCmd.push('E2E_SKIP_CLEANUP=1'); + } + + playwrightCmd.push('playwright', 'test', ...quotedArgs); + + // Add -x flag for fail-fast mode when E2E_RETRIES=0 + if (process.env.E2E_RETRIES === '0' && !quotedArgs.includes('-x')) { + playwrightCmd.push('-x'); + } + + if (!quietMode) console.log('Running:', playwrightCmd.join(' ')); + execSync(playwrightCmd.join(' '), { + stdio: 'inherit', + env: process.env, + shell: true, + }); + return true; + } catch (error) { + return false; + } +} + +async function main() { + let attempt = 0; + + // Get additional args passed to the script (e.g., test file names, -g patterns) + const extraArgs = process.argv.slice(2); + const verboseMode = process.env.VERBOSE === '1'; + const quietMode = !verboseMode; + + while (attempt <= RETRIES) { + if (attempt > 0) { + if (!quietMode) { + console.log('\n=========================================='); + console.log(`Test attempt ${attempt} failed, running teardown and retrying failed tests only...`); + console.log('==========================================\n'); + } + await runTeardown(); + } + + // On retry, use --last-failed only if there were actual test failures + // If global setup failed before tests ran, run all tests instead + const playwrightArgs = attempt > 0 ? ['--last-failed', ...extraArgs] : extraArgs; + + // Add --quiet flag if QUIET is set + if (quietMode && !playwrightArgs.includes('--quiet')) { + playwrightArgs.push('--quiet'); + } + + // First attempt: build bundle and run tests + if (attempt === 0 && !process.env.SKIP_BUNDLE) { + if (!quietMode) console.log('Building test bundle...'); + try { + execSync('npm run test:bundle', { stdio: quietMode ? 'ignore' : 'inherit' }); + } catch (error) { + console.error('Failed to build test bundle'); + process.exit(1); + } + } else if (attempt === 0 && process.env.SKIP_BUNDLE) { + if (!quietMode) console.log('Skipping bundle build (SKIP_BUNDLE=1)'); + } + + if (!quietMode) console.log(attempt > 0 ? 'Running failed tests...' : 'Running all tests...'); + const success = runPlaywright(playwrightArgs, quietMode, attempt > 0); + + if (success) { + if (!quietMode) { + console.log('\n=========================================='); + console.log(attempt > 0 + ? `Tests passed on attempt ${attempt} (after retrying failed tests)` + : `Tests passed on attempt ${attempt}`); + console.log('==========================================\n'); + } + process.exit(0); + } + + attempt++; + } + + if (!quietMode) { + console.log('\n=========================================='); + console.log(`Tests failed after ${RETRIES} retries`); + console.log('==========================================\n'); + } + process.exit(1); +} + +main(); diff --git a/frontend/e2e/teardown.ts b/frontend/e2e/teardown.ts new file mode 100644 index 000000000000..07bf1298ea45 --- /dev/null +++ b/frontend/e2e/teardown.ts @@ -0,0 +1,66 @@ +import fetch from 'node-fetch'; +import Project from '../common/project'; + +// Load environment variables +require('dotenv').config(); + +export async function runTeardown(): Promise { + console.log('\n\x1b[36m%s\x1b[0m\n', 'Running E2E teardown...'); + + const e2eTestApi = `${process.env.FLAGSMITH_API_URL || Project.api}e2etests/teardown/`; + const token = process.env.E2E_TEST_TOKEN + ? process.env.E2E_TEST_TOKEN + : process.env[`E2E_TEST_TOKEN_${Project.env.toUpperCase()}`]; + + if (!token) { + console.error('\x1b[31m%s\x1b[0m\n', 'Error: No E2E_TEST_TOKEN found'); + return false; + } + + const maxAttempts = 3; + const delayMs = 2000; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (attempt > 0) { + console.log(`\x1b[33m%s\x1b[0m`, `Retrying teardown (attempt ${attempt + 1}/${maxAttempts})...`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + + try { + const res = await fetch(e2eTestApi, { + body: JSON.stringify({}), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-E2E-Test-Auth-Token': token.trim(), + }, + method: 'POST', + }); + + if (res.ok) { + console.log('\x1b[32m%s\x1b[0m\n', '✓ E2E teardown successful'); + return true; + } else { + console.error('\x1b[31m%s\x1b[0m', `✗ E2E teardown failed: ${res.status}`); + if (attempt < maxAttempts - 1) { + console.log(''); + } + } + } catch (error) { + console.error('\x1b[31m%s\x1b[0m', `✗ E2E teardown error: ${error}`); + if (attempt < maxAttempts - 1) { + console.log(''); + } + } + } + + console.log('\x1b[31m%s\x1b[0m\n', `✗ E2E teardown failed after ${maxAttempts} attempts`); + return false; +} + +// When run directly as a script +if (require.main === module) { + runTeardown().then(success => { + process.exit(success ? 0 : 1); + }); +} diff --git a/frontend/e2e/test-setup.ts b/frontend/e2e/test-setup.ts new file mode 100644 index 000000000000..69d00de7f09c --- /dev/null +++ b/frontend/e2e/test-setup.ts @@ -0,0 +1,15 @@ +import { test as base } from '@playwright/test'; +import { setupBrowserLogging } from './helpers.playwright'; + +// Extend base test to automatically setup browser logging +export const test = base.extend({ + page: async ({ page }, use) => { + // Setup logging before each test + setupBrowserLogging(page); + + // Use the page in the test + await use(page); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/frontend/e2e/tests/environment-permission-test.pw.ts b/frontend/e2e/tests/environment-permission-test.pw.ts new file mode 100644 index 000000000000..93424dd8883d --- /dev/null +++ b/frontend/e2e/tests/environment-permission-test.pw.ts @@ -0,0 +1,151 @@ +import { test, expect } from '../test-setup'; +import { + byId, + click, clickByText, closeModal, createEnvironment, + createFeature, editRemoteConfig, + gotoFeature, + gotoTraits, + log, + login, logout, setUserPermission, + toggleFeature, waitForElementClickable, waitForElementNotClickable, waitForElementNotExist, waitForElementVisible, + waitForFeatureSwitchClickable, + createHelpers, +} from '../helpers.playwright'; +import { + PASSWORD, + E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, + E2E_USER, +} from '../config'; + +test.describe('Environment Permission Tests', () => { + test('test description @enterprise', async ({ page }) => { + const helpers = createHelpers(page); + log('Login') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + log('User can only view project') + await helpers.click('#project-select-0') + await expect(page.locator('#project-select-1')) + .not.toBeVisible({ timeout: 5000 }) + await helpers.logout() + + log('User with permissions can Handle the Features') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await createFeature(page, 0, 'test_feature', false) + await toggleFeature(page, 'test_feature', true) + await helpers.logout() + + log('User without permissions cannot create traits') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await gotoTraits(page) + const createTraitBtn = page.locator(byId('add-trait')) + await expect(createTraitBtn).toBeDisabled() + await helpers.logout() + + log('User without permissions cannot see audit logs') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.waitForElementNotExist(byId('audit-log-link')) + await helpers.logout() + + log('Create new environment') + await helpers.login(E2E_USER, PASSWORD) + await helpers.clickByText('My Test Project 6 Env Permission') + await helpers.click('#create-env-link') + await createEnvironment(page, 'Production') + await helpers.logout() + log('User without permissions cannot see environment') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.waitForElementVisible(byId('switch-environment-development')) + await helpers.waitForElementNotExist(byId('switch-environment-production')) + await helpers.logout() + + log('Grant view environment permission') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'VIEW_ENVIRONMENT', 'Production', 'environment', 'My Test Project 6 Env Permission' ) + await helpers.logout() + log('User with permissions can see environment') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.waitForElementVisible(byId('switch-environment-production')) + await helpers.waitForElementVisible(byId('switch-environment-production')) + await helpers.logout() + + log('User with permissions can update feature state') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await createFeature(page, 0,'my_feature',"foo",'A test feature') + await editRemoteConfig(page, 'my_feature', 'bar') + await helpers.logout() + log('User without permission cannot create a segment override') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await gotoFeature(page, 'my_feature') + await helpers.click(byId('segment_overrides')) + await waitForElementNotClickable(page, '#update-feature-segments-btn') + await closeModal(page) + await helpers.logout() + log('Grant MANAGE_IDENTITIES permission') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'MANAGE_SEGMENT_OVERRIDES', 'Development', 'environment', 'My Test Project 6 Env Permission' ) + await helpers.logout() + log('User with permission can create a segment override') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await gotoFeature(page, 'my_feature') + await helpers.click(byId('segment_overrides')) + await waitForElementClickable(page, '#update-feature-segments-btn') + await closeModal(page) + await helpers.logout() + + log('User without permissions cannot update feature state') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await waitForFeatureSwitchClickable(page, 'test_feature', 'on', true) + await helpers.click(byId('switch-environment-production')) + await waitForFeatureSwitchClickable(page, 'test_feature', 'off', false) + await gotoFeature(page, 'test_feature') + await waitForElementNotClickable(page, byId('update-feature-btn')) + await closeModal(page) + await helpers.logout() + + log('User with permissions can view identities') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.waitForElementVisible('#users-link') + await helpers.logout() + + log('User without permissions cannot add user trait') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.click('#users-link') + await helpers.click(byId('user-item-0')) + await waitForElementNotClickable(page, byId('add-trait')) + await helpers.logout() + + log('Grant MANAGE_IDENTITIES permission') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'MANAGE_IDENTITIES', 'Development', 'environment', 'My Test Project 6 Env Permission' ) + await helpers.logout() + log('User with permissions can add user trait') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.click('#users-link') + await helpers.click(byId('user-item-0')) + await waitForElementClickable(page, byId('add-trait')) + await helpers.logout() + + + log('Remove VIEW_IDENTITIES permission') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'VIEW_IDENTITIES', 'Development', 'environment', 'My Test Project 6 Env Permission' ) + await helpers.logout() + log('User without permissions cannot view identities') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.click('#users-link') + await helpers.waitForElementVisible(byId('missing-view-identities')) + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/environment-permission-test.ts b/frontend/e2e/tests/environment-permission-test.ts deleted file mode 100644 index ea072faebb72..000000000000 --- a/frontend/e2e/tests/environment-permission-test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - byId, - click, clickByText, closeModal, createEnvironment, - createFeature, editRemoteConfig, - gotoTraits, - log, - login, logout, setUserPermission, - toggleFeature, waitForElementClickable, waitForElementNotClickable, waitForElementNotExist, waitForElementVisible, -} from '../helpers.cafe'; -import { - PASSWORD, - E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, - E2E_USER, -} from '../config'; -import { Selector, t } from 'testcafe' - -export default async function () { - log('Login') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - log('User can only view project') - await click('#project-select-0') - await t - .expect(Selector('#project-select-1').exists) - .notOk('The element"#project-select-1" should not be present') - await logout() - - log('User with permissions can Handle the Features') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await createFeature(0, 'test_feature', false) - await toggleFeature(0, true) - await logout() - - log('User without permissions cannot create traits') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await gotoTraits() - const createTraitBtn = Selector(byId('add-trait')) - await t.expect(createTraitBtn.hasAttribute('disabled')).ok() - await logout() - - log('User without permissions cannot see audit logs') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementNotExist(byId('audit-log-link')) - await logout() - - log('Create new environment') - await login(E2E_USER, PASSWORD) - await clickByText('My Test Project 6 Env Permission') - await click('#create-env-link') - await createEnvironment('Production') - await logout() - log('User without permissions cannot see environment') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementVisible(byId('switch-environment-development')) - await waitForElementNotExist(byId('switch-environment-production')) - await logout() - - log('Grant view environment permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'VIEW_ENVIRONMENT', 'Production', 'environment', 'My Test Project 6 Env Permission' ) - await logout() - log('User with permissions can see environment') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementVisible(byId('switch-environment-production')) - await waitForElementVisible(byId('switch-environment-production')) - await logout() - - log('User with permissions can update feature state') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await createFeature(0,'my_feature',"foo",'A test feature') - await editRemoteConfig(0, 'bar') - await logout() - log('User without permission cannot create a segment override') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('feature-item-0')) - await click(byId('segment_overrides')) - await waitForElementNotClickable('#update-feature-segments-btn') - await closeModal() - await logout() - log('Grant MANAGE_IDENTITIES permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'MANAGE_SEGMENT_OVERRIDES', 'Development', 'environment', 'My Test Project 6 Env Permission' ) - await logout() - log('User with permission can create a segment override') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('feature-item-0')) - await click(byId('segment_overrides')) - await waitForElementClickable('#update-feature-segments-btn') - await closeModal() - await logout() - - log('User without permissions cannot update feature state') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementClickable(byId('feature-switch-0-on')) - await click(byId('switch-environment-production')) - await waitForElementNotClickable(byId('feature-switch-0-on')) - await click(byId('feature-item-0')) - await waitForElementNotClickable(byId('update-feature-btn')) - await closeModal() - await logout() - - log('User with permissions can view identities') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementVisible('#users-link') - await logout() - - log('User without permissions cannot add user trait') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click('#users-link') - await click(byId('user-item-0')) - await waitForElementNotClickable(byId('add-trait')) - await logout() - - log('Grant MANAGE_IDENTITIES permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'MANAGE_IDENTITIES', 'Development', 'environment', 'My Test Project 6 Env Permission' ) - await logout() - log('User with permissions can add user trait') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click('#users-link') - await click(byId('user-item-0')) - await waitForElementClickable(byId('add-trait')) - await logout() - - - log('Remove VIEW_IDENTITIES permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'VIEW_IDENTITIES', 'Development', 'environment', 'My Test Project 6 Env Permission' ) - await logout() - log('User without permissions cannot view identities') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click('#users-link') - await waitForElementVisible(byId('missing-view-identities')) -} diff --git a/frontend/e2e/tests/environment-test.pw.ts b/frontend/e2e/tests/environment-test.pw.ts new file mode 100644 index 000000000000..5164e6892465 --- /dev/null +++ b/frontend/e2e/tests/environment-test.pw.ts @@ -0,0 +1,34 @@ +import { test, expect } from '../test-setup'; +import { + byId, + click, + createEnvironment, + log, + login, + setText, + waitForElementVisible, + createHelpers, +} from '../helpers.playwright'; +import { PASSWORD, E2E_USER } from '../config' + +test.describe('Environment Tests', () => { + test('test description @oss', async ({ page }) => { + const helpers = createHelpers(page); + log('Login') + await helpers.login(E2E_USER, PASSWORD) + await helpers.click('#project-select-0') + log('Create environment') + await helpers.click('#create-env-link') + await createEnvironment(page, 'Staging') + log('Edit Environment') + await helpers.click('#env-settings-link') + await helpers.setText("[name='env-name']", 'Internal') + await helpers.click('#save-env-btn') + await helpers.waitForElementVisible(byId('switch-environment-internal-active')) + log('Delete environment') + await helpers.click('#delete-env-btn') + await helpers.setText("[name='confirm-env-name']", 'Internal') + await helpers.click('#confirm-delete-env-btn') + await helpers.waitForElementVisible(byId('features-page')) + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/environment-test.ts b/frontend/e2e/tests/environment-test.ts deleted file mode 100644 index 7749d32e0971..000000000000 --- a/frontend/e2e/tests/environment-test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - byId, - click, - createEnvironment, - log, - login, - setText, - waitForElementVisible, -} from '../helpers.cafe'; -import { PASSWORD, E2E_USER } from '../config' - -export default async function () { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-0') - log('Create environment') - await click('#create-env-link') - await createEnvironment('Staging') - log('Edit Environment') - await click('#env-settings-link') - await setText("[name='env-name']", 'Internal') - await click('#save-env-btn') - await waitForElementVisible(byId('switch-environment-internal-active')) - log('Delete environment') - await click('#delete-env-btn') - await setText("[name='confirm-env-name']", 'Internal') - await click('#confirm-delete-env-btn') - await waitForElementVisible(byId('features-page')) -} diff --git a/frontend/e2e/tests/flag-tests.pw.ts b/frontend/e2e/tests/flag-tests.pw.ts new file mode 100644 index 000000000000..fb6ac676852a --- /dev/null +++ b/frontend/e2e/tests/flag-tests.pw.ts @@ -0,0 +1,115 @@ +import { test, expect } from '../test-setup'; +import { + byId, + click, + closeModal, + createFeature, + createRemoteConfig, + deleteFeature, + editRemoteConfig, + log, + login, + parseTryItResults, + scrollBy, + toggleFeature, + waitForElementVisible, + waitForFeatureSwitch, + waitForNetworkIdle, + createHelpers, +} from '../helpers.playwright'; +import { E2E_USER, PASSWORD } from '../config'; + +test.describe('Flag Tests', () => { + test('test description @oss', async ({ page }) => { + const helpers = createHelpers(page); + log('Login') + await helpers.login(E2E_USER, PASSWORD) + await helpers.click('#project-select-0') + + // Check if we're already in production by checking if development is clickable + const isProductionActive = await page.locator(byId('switch-environment-development')).isVisible().catch(() => true) + + if (isProductionActive) { + // We're not in development (might be in production), switch to development first + log('Switching to development first') + await helpers.waitForElementClickable(byId('switch-environment-development')) + await helpers.click(byId('switch-environment-development')) + await page.waitForTimeout(500) + } + + log('Create Features') + await helpers.click('#features-link') + + await createFeature(page, 0, 'header_enabled', false) + await createRemoteConfig(page, 1, 'header_size', 'big') + await createRemoteConfig(page, 2, 'mv_flag', 'big', null, null, [ + { value: 'medium', weight: 100 }, + { value: 'small', weight: 0 }, + ]) + + log('Create Short Life Feature') + await createFeature(page, 3, 'short_life_feature', false) + await scrollBy(page, 0, 15000) + + log('Delete Short Life Feature') + await deleteFeature(page, 'short_life_feature') + await scrollBy(page, 0, 30000) + + log('Toggle Feature') + await toggleFeature(page, 'header_enabled', true) + + log('Try it') + await page.waitForTimeout(2000) + await helpers.click('#try-it-btn') + await page.waitForTimeout(500) + let json = await parseTryItResults(page) + await expect(json.header_size.value).toBe('big') + await expect(json.mv_flag.value).toBe('big') + await expect(json.header_enabled.enabled).toBe(true) + + log('Update feature') + await editRemoteConfig(page, 'header_size', 12) + + log('Try it again') + await page.waitForTimeout(5000) + await helpers.click('#try-it-btn') + await page.waitForTimeout(2000) + json = await parseTryItResults(page) + await expect(json.header_size.value).toBe(12) + + log('Change feature value to boolean') + await editRemoteConfig(page, 'header_size', false) + + log('Try it again 2') + await page.waitForTimeout(5000) + await helpers.click('#try-it-btn') + await page.waitForTimeout(2000) + json = await parseTryItResults(page) + await expect(json.header_size.value).toBe(false) + + log('Switch environment') + // Navigate back to features list so environment switcher is visible in navbar + await helpers.gotoFeatures() + // Wait for page to be fully loaded and features page to be ready + await page.waitForLoadState('load') + await helpers.waitForElementVisible('#show-create-feature-btn') + + // Wait a moment for environment switcher to render + await page.waitForTimeout(500) + + // Now we're definitely in development, switch to production + log('Switching to production') + await helpers.waitForElementClickable(byId('switch-environment-production')) + await helpers.click(byId('switch-environment-production')) + + log('Feature should be off under different environment') + await helpers.waitForElementVisible(byId('switch-environment-production-active')) + await waitForFeatureSwitch(page, 'header_enabled', 'off') + + log('Clear down features') + // Ensure features list is fully loaded before attempting to delete + await waitForFeatureSwitch(page, 'header_enabled', 'off') + await deleteFeature(page, 'header_size') + await deleteFeature(page, 'header_enabled') + }); +}); diff --git a/frontend/e2e/tests/flag-tests.ts b/frontend/e2e/tests/flag-tests.ts deleted file mode 100644 index 09c61eec818e..000000000000 --- a/frontend/e2e/tests/flag-tests.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - byId, - click, - closeModal, - createFeature, - createRemoteConfig, - deleteFeature, - editRemoteConfig, - log, - login, - parseTryItResults, - toggleFeature, - waitForElementVisible, -} from '../helpers.cafe'; -import { t } from 'testcafe'; -import { E2E_USER, PASSWORD } from '../config'; - -export default async function () { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-0') - - log('Create Features') - await click('#features-link') - - await createRemoteConfig(0, 'header_size', 'big') - await createRemoteConfig(0, 'mv_flag', 'big', null, null, [ - { value: 'medium', weight: 100 }, - { value: 'small', weight: 0 }, - ]) - await createFeature(1, 'header_enabled', false) - - log('Create Short Life Feature') - await createFeature(3, 'short_life_feature', false) - await t.eval(() => { - window.scrollBy(0, 15000) - }) - - log('Delete Short Life Feature') - await deleteFeature(3, 'short_life_feature') - await t.eval(() => { - window.scrollBy(0, 30000) - }) - - log('Toggle Feature') - await toggleFeature(0, true) - - log('Try it') - await t.wait(2000) - await click('#try-it-btn') - await t.wait(500) - let json = await parseTryItResults() - await t.expect(json.header_size.value).eql('big') - await t.expect(json.mv_flag.value).eql('big') - await t.expect(json.header_enabled.enabled).eql(true) - - log('Update feature') - await editRemoteConfig(1,12) - - log('Try it again') - await t.wait(500) - await click('#try-it-btn') - await t.wait(500) - json = await parseTryItResults() - await t.expect(json.header_size.value).eql(12) - - log('Change feature value to boolean') - await editRemoteConfig(1,false) - - log('Try it again 2') - await t.wait(500) - await click('#try-it-btn') - await t.wait(500) - json = await parseTryItResults() - await t.expect(json.header_size.value).eql(false) - - log('Switch environment') - await click(byId('switch-environment-production')) - - log('Feature should be off under different environment') - await waitForElementVisible(byId('switch-environment-production-active')) - await waitForElementVisible(byId('feature-switch-0-off')) - - log('Clear down features') - await deleteFeature(1, 'header_size') - await deleteFeature(0, 'header_enabled') -} diff --git a/frontend/e2e/tests/initialise-tests.pw.ts b/frontend/e2e/tests/initialise-tests.pw.ts new file mode 100644 index 000000000000..6db9f417f3d9 --- /dev/null +++ b/frontend/e2e/tests/initialise-tests.pw.ts @@ -0,0 +1,61 @@ +import { test } from '../test-setup'; +import { + byId, + createHelpers, + getFlagsmith, + log, +} from '../helpers.playwright'; +import { E2E_SIGN_UP_USER, PASSWORD } from '../config'; + +test.describe('Signup', () => { + test('Create Organisation and Project @oss', async ({ page }) => { + const helpers = createHelpers(page); + const flagsmith = await getFlagsmith(); + + // Add error logging + await helpers.addErrorLogging(); + + // Navigate to signup page + await page.goto('/'); + + log('Create Organisation'); + await helpers.click(byId('jsSignup')); + // Wait for firstName field to be visible after modal opens + await helpers.waitForElementVisible(byId('firstName')); + await helpers.setText(byId('firstName'), 'Bullet'); + await helpers.setText(byId('lastName'), 'Train'); + await helpers.setText(byId('email'), E2E_SIGN_UP_USER); + await helpers.setText(byId('password'), PASSWORD); + await helpers.click(byId('signup-btn')); + // Wait for navigation and form to load after signup + await page.waitForURL(/\/create/, { timeout: 20000 }); + await helpers.waitForElementVisible('[name="orgName"]'); + await helpers.setText('[name="orgName"]', 'Flagsmith Ltd 0'); + await helpers.click('#create-org-btn'); + + if (flagsmith.hasFeature('integration_onboarding')) { + await helpers.click(byId('integration-0')); + await helpers.click(byId('integration-1')); + await helpers.click(byId('integration-2')); + await helpers.click(byId('submit-integrations')); + } + await helpers.click(byId('create-project')); + + log('Create Project'); + await helpers.click(byId('create-first-project-btn')); + await helpers.setText(byId('projectName'), 'My Test Project'); + await helpers.click(byId('create-project-btn')); + await helpers.waitForElementVisible(byId('features-page')); + + log('Hide disabled flags'); + await helpers.click('#project-link'); + await helpers.click('#project-settings-link'); + await helpers.click(byId('js-sdk-settings')); + await helpers.click(byId('js-hide-disabled-flags')); + await helpers.setText(byId('js-project-name'), 'My Test Project'); + await helpers.click(byId('js-confirm')); + + // Logout after test + await helpers.logout(); + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/initialise-tests.ts b/frontend/e2e/tests/initialise-tests.ts deleted file mode 100644 index e32fb37d47d0..000000000000 --- a/frontend/e2e/tests/initialise-tests.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - byId, - click, - getFlagsmith, - log, - setText, - waitForElementVisible, -} from '../helpers.cafe'; -import { E2E_SIGN_UP_USER, PASSWORD } from '../config' - -export default async function () { - const flagsmith = await getFlagsmith() - log('Create Organisation') - await click(byId('jsSignup')) - await setText(byId('firstName'), 'Bullet') // visit the url - await setText(byId('lastName'), 'Train') // visit the url - await setText(byId('email'), E2E_SIGN_UP_USER) // visit the url - await setText(byId('password'), PASSWORD) // visit the url - await click(byId('signup-btn')) - await setText('[name="orgName"]', 'Flagsmith Ltd 0') - await click('#create-org-btn') - - if(flagsmith.hasFeature("integration_onboarding")) { - await click(byId("integration-0")) - await click(byId("integration-1")) - await click(byId("integration-2")) - await click(byId("submit-integrations")) - } - await click(byId('create-project')) - - log('Create Project') - await click(byId('create-first-project-btn')) - await setText(byId('projectName'), 'My Test Project') - await click(byId('create-project-btn')) - await waitForElementVisible(byId('features-page')) - - log('Hide disabled flags') - await click('#project-link') - await click('#project-settings-link') - await click(byId('js-sdk-settings')) - await click(byId('js-hide-disabled-flags')) - await setText(byId('js-project-name'), 'My Test Project') - await click(byId('js-confirm')) -} diff --git a/frontend/e2e/tests/invite-test.pw.ts b/frontend/e2e/tests/invite-test.pw.ts new file mode 100644 index 000000000000..a604fea6e28d --- /dev/null +++ b/frontend/e2e/tests/invite-test.pw.ts @@ -0,0 +1,47 @@ +import { test } from '../test-setup'; +import { assertTextContent, byId, createHelpers, getInputValue, log } from '../helpers.playwright'; +import { E2E_CHANGE_MAIL, E2E_USER, PASSWORD } from '../config'; + +const invitePrefix = `flagsmith${new Date().valueOf()}` +const inviteEmail = `${invitePrefix}@restmail.net` +test.describe('Invite Tests', () => { + test('test description @oss', async ({ page }) => { + const helpers = createHelpers(page); + log('Login') + await helpers.login(E2E_USER, PASSWORD) + log('Get Invite url') + await helpers.waitForElementVisible(byId('organisation-link')) + await helpers.click(byId('organisation-link')) + await helpers.waitForElementVisible(byId('org-settings-link')) + await helpers.click(byId('org-settings-link')) + await getInputValue(page, byId('organisation-name')) + await helpers.click(byId('users-and-permissions')) + const inviteLink = await getInputValue(page, byId('invite-link')) + log('Accept invite') + await page.goto(inviteLink) + // Wait for the form to load + await helpers.waitForElementVisible(byId('firstName')) + await helpers.setText(byId('firstName'), 'Bullet') + await helpers.setText(byId('lastName'), 'Train') + await helpers.setText(byId('email'), inviteEmail) + await helpers.setText(byId('password'), PASSWORD) + await helpers.waitForElementVisible(byId('signup-btn')) + // Wait for form validation to complete before clicking + await page.waitForTimeout(500) + await helpers.click(byId('signup-btn')) + log('Change email') + await helpers.click(byId('account-settings-link')) + await helpers.click(byId('change-email-button')) + await helpers.setText("[name='EmailAddress']", E2E_CHANGE_MAIL) + await helpers.setText("[name='newPassword']", PASSWORD) + await helpers.click('#save-changes') + await helpers.waitForElementNotExist('.modal') + await helpers.login(E2E_CHANGE_MAIL, PASSWORD) + log('Delete invite user') + await assertTextContent(page, '[id=account-settings-link]', 'Account') + await helpers.click(byId('account-settings-link')) + await helpers.click(byId('delete-user-btn')) + await helpers.setText("[name='currentPassword']", PASSWORD) + await helpers.click(byId('delete-account')) + }); +}); diff --git a/frontend/e2e/tests/invite-test.ts b/frontend/e2e/tests/invite-test.ts deleted file mode 100644 index 12bd356d1859..000000000000 --- a/frontend/e2e/tests/invite-test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - assertTextContent, - byId, - click, - log, - login, - setText, - waitForElementVisible, -} from '../helpers.cafe' -import { Selector, t } from 'testcafe' -import { E2E_CHANGE_MAIL, E2E_USER, PASSWORD } from '../config' - -const invitePrefix = `flagsmith${new Date().valueOf()}` -const inviteEmail = `${invitePrefix}@restmail.net` -export default async function () { - log('Login') - await login(E2E_USER, PASSWORD) - log('Get Invite url') - await waitForElementVisible(byId('organisation-link')) - await click(byId('organisation-link')) - await waitForElementVisible(byId('org-settings-link')) - await click(byId('org-settings-link')) - await Selector(byId('organisation-name')).value - await click(byId('users-and-permissions')) - const inviteLink = await Selector(byId('invite-link')).value - log('Accept invite') - await t.navigateTo(inviteLink) - await setText('[name="email"]', inviteEmail) - await setText(byId('firstName'), 'Bullet') // visit the url - await setText(byId('lastName'), 'Train') - await setText(byId('email'), inviteEmail) - await setText(byId('password'), PASSWORD) - await waitForElementVisible(byId('signup-btn')) - await click(byId('signup-btn')) - log('Change email') - await click(byId('account-settings-link')) - await click(byId('change-email-button')) - await setText("[name='EmailAddress']", E2E_CHANGE_MAIL) - await setText("[name='newPassword']", PASSWORD) - await click('#save-changes') - await login(E2E_CHANGE_MAIL, PASSWORD) - log('Delete invite user') - await assertTextContent('[id=account-settings-link]', 'Account') - await click(byId('account-settings-link')) - await click(byId('delete-user-btn')) - await setText("[name='currentPassword']", PASSWORD) - await click(byId('delete-account')) -} diff --git a/frontend/e2e/tests/organisation-permission-test.pw.ts b/frontend/e2e/tests/organisation-permission-test.pw.ts new file mode 100644 index 000000000000..b7341afe0ebf --- /dev/null +++ b/frontend/e2e/tests/organisation-permission-test.pw.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../test-setup'; +import { + byId, + click, clickByText, + closeModal, + log, + login, logout, + setText, waitForElementClickable, waitForElementNotClickable, + waitForElementVisible, + createHelpers, +} from '../helpers.playwright'; +import { + PASSWORD, + E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, + E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, +} from '../config'; + +test.describe('Organisation Permission Tests', () => { + test('test description @enterprise', async ({ page }) => { + const helpers = createHelpers(page); + log('Login') + await helpers.login(E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, PASSWORD) + log('User without permissions cannot see any Project') + await expect(page.locator('#project-select-0')).not.toBeVisible() + log('User with permissions can Create a Project') + await waitForElementClickable(page, byId('create-first-project-btn')) + + log('User can manage groups') + await helpers.click(byId('users-and-permissions')) + await helpers.clickByText('Groups') + await waitForElementClickable(page, "#btn-invite-groups") + await helpers.logout() + log('Login as project user') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + log('User cannot manage users or groups') + await helpers.click(byId('users-and-permissions')) + await helpers.clickByText('Groups') + await waitForElementNotClickable(page, "#btn-invite-groups") + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/organisation-permission-test.ts b/frontend/e2e/tests/organisation-permission-test.ts deleted file mode 100644 index 24456a18637d..000000000000 --- a/frontend/e2e/tests/organisation-permission-test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - byId, - click, clickByText, - closeModal, - log, - login, logout, - setText, waitForElementClickable, waitForElementNotClickable, - waitForElementVisible, -} from '../helpers.cafe'; -import { - PASSWORD, - E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, - E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, -} from '../config'; -import { Selector, t } from 'testcafe' - -export default async function () { - log('Login') - await login(E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, PASSWORD) - log('User without permissions cannot see any Project') - await t - .expect(Selector('#project-select-0').exists) - .notOk('The element"#project-select-0" should not be present') - log('User with permissions can Create a Project') - await waitForElementClickable( byId('create-first-project-btn')) - - log('User can manage groups') - await click(byId('users-and-permissions')) - await clickByText('Groups') - await waitForElementClickable("#btn-invite-groups") - await logout() - log('Login as project user') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - log('User cannot manage users or groups') - await click(byId('users-and-permissions')) - await clickByText('Groups') - await waitForElementNotClickable("#btn-invite-groups") -} diff --git a/frontend/e2e/tests/organisation-test.pw.ts b/frontend/e2e/tests/organisation-test.pw.ts new file mode 100644 index 000000000000..8109b0a3d548 --- /dev/null +++ b/frontend/e2e/tests/organisation-test.pw.ts @@ -0,0 +1,136 @@ +import { test, expect } from '../test-setup'; +import { + assertTextContent, + byId, + click, + closeModal, + getText, + log, + login, + setText, + waitForElementVisible, + waitForElementNotExist, + clickByText, + createHelpers, +} from '../helpers.playwright' +import { E2E_SEPARATE_TEST_USER, PASSWORD } from '../config' + +test.describe('Organisation Tests', () => { + test('test description @oss', async ({ page }) => { + const helpers = createHelpers(page); + log('Login') + await helpers.login(E2E_SEPARATE_TEST_USER, PASSWORD) + + log('Navigate to Organisation Settings') + await helpers.waitForElementVisible(byId('organisation-link')) + await helpers.click(byId('organisation-link')) + await helpers.waitForElementVisible(byId('org-settings-link')) + await helpers.click(byId('org-settings-link')) + + log('Edit Organisation Name') + await helpers.waitForElementVisible("[data-test='organisation-name']") + await helpers.setText("[data-test='organisation-name']", 'Test Organisation') + await helpers.click('#save-org-btn') + + log('Verify Organisation Name Updated in Breadcrumb') + await helpers.click('#projects-link') + await assertTextContent(page, '#organisation-link', 'Test Organisation') + + log('Verify Organisation Name Persisted in Settings') + await helpers.click(byId('organisation-link')) + await helpers.waitForElementVisible(byId('org-settings-link')) + await helpers.click(byId('org-settings-link')) + await helpers.waitForElementVisible("[data-test='organisation-name']") + + log('Test 2: Create and Delete Organisation, Verify Next Org in Nav') + log('Navigate to create organisation') + await helpers.click(byId('home-link')) + await helpers.waitForElementVisible(byId('create-organisation-btn')) + await helpers.click(byId('create-organisation-btn')) + + log('Create New Organisation') + await helpers.waitForElementVisible("[name='orgName']") + await helpers.setText("[name='orgName']", 'E2E Test Org to Delete') + await helpers.click('#create-org-btn') + + log('Verify New Organisation Created and appears in nav') + await helpers.waitForElementVisible(byId('organisation-link')) + await assertTextContent(page, '#organisation-link', 'E2E Test Org to Delete') + + log('Navigate back to the org we want to delete') + await helpers.waitForElementVisible(byId('org-settings-link')) + await helpers.click(byId('org-settings-link')) + + log('Delete Organisation') + await helpers.waitForElementVisible('#delete-org-btn') + await helpers.click('#delete-org-btn') + await helpers.setText("[name='confirm-org-name']", 'E2E Test Org to Delete') + await helpers.clickByText('Confirm') + + log('Verify Redirected to Next Organisation in Nav') + await helpers.waitForElementVisible(byId('organisation-link')) + log('Current org in nav after deletion: Test Organisation') + + log('Verify deleted org name does not appear in nav') + // Wait for the organisation link to update to the new org before asserting + await assertTextContent(page, '#organisation-link', 'Test Organisation') + await expect(page.locator('#organisation-link')).not.toContainText('E2E Test Org to Delete') + + log('Test 3: Cancel Organisation Deletion') + log('Create temporary organisation for cancel test') + await helpers.click(byId('home-link')) + await helpers.waitForElementVisible(byId('create-organisation-btn')) + await helpers.click(byId('create-organisation-btn')) + await helpers.waitForElementVisible("[name='orgName']") + await helpers.setText("[name='orgName']", 'E2E Cancel Test Org') + await helpers.click('#create-org-btn') + + log('Navigate to org settings and open delete modal') + await helpers.waitForElementVisible(byId('organisation-link')) + await assertTextContent(page, '#organisation-link', 'E2E Cancel Test Org') + await helpers.waitForElementVisible(byId('org-settings-link')) + await helpers.click(byId('org-settings-link')) + await helpers.waitForElementVisible('#delete-org-btn') + await helpers.click('#delete-org-btn') + await helpers.waitForElementVisible("[name='confirm-org-name']") + await helpers.setText("[name='confirm-org-name']", 'E2E Cancel Test Org') + + log('Close modal without confirming deletion') + await closeModal(page) + await helpers.waitForElementNotExist('.modal') + + log('Verify organisation still exists in navbar') + await helpers.waitForElementVisible(byId('organisation-link')) + await assertTextContent(page, '#organisation-link', 'E2E Cancel Test Org') + + log('Clean up: Delete the test organisation') + await helpers.click('#delete-org-btn') + await helpers.setText("[name='confirm-org-name']", 'E2E Cancel Test Org') + await helpers.clickByText('Confirm') + await helpers.waitForElementNotExist('.modal') + await helpers.waitForElementVisible(byId('organisation-link')) + await assertTextContent(page, '#organisation-link', 'Test Organisation') + + log('Test 4: Organisation Name Validation') + log('Navigate to Test Organisation settings') + await helpers.waitForElementVisible(byId('org-settings-link')) + await helpers.click(byId('org-settings-link')) + + log('Test empty organisation name validation') + await helpers.waitForElementVisible("[data-test='organisation-name']") + const originalName = await helpers.getInputValue("[data-test='organisation-name']") + + log('Clear organisation name') + await helpers.setText("[data-test='organisation-name']", '') + + log('Verify save button is disabled') + const saveButton = page.locator('#save-org-btn') + await expect(saveButton).toBeDisabled() + + log('Restore original name') + await helpers.setText("[data-test='organisation-name']", originalName) + + log('Verify save button is enabled') + await expect(saveButton).not.toBeDisabled() + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/organisation-test.ts b/frontend/e2e/tests/organisation-test.ts deleted file mode 100644 index c9d2bc88c8c1..000000000000 --- a/frontend/e2e/tests/organisation-test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { - assertTextContent, - byId, - click, - closeModal, - getText, - log, - login, - setText, - waitForElementVisible, - waitForElementNotExist, - clickByText, -} from '../helpers.cafe' -import { E2E_SEPARATE_TEST_USER, PASSWORD } from '../config' -import { Selector, t } from 'testcafe' - -export default async function () { - log('Login') - await login(E2E_SEPARATE_TEST_USER, PASSWORD) - - log('Navigate to Organisation Settings') - await waitForElementVisible(byId('organisation-link')) - await click(byId('organisation-link')) - await waitForElementVisible(byId('org-settings-link')) - await click(byId('org-settings-link')) - - log('Edit Organisation Name') - await waitForElementVisible("[data-test='organisation-name']") - await setText("[data-test='organisation-name']", 'Test Organisation') - await click('#save-org-btn') - - log('Verify Organisation Name Updated in Breadcrumb') - await click('#projects-link') - await assertTextContent('#organisation-link', 'Test Organisation') - - log('Verify Organisation Name Persisted in Settings') - await click(byId('organisation-link')) - await waitForElementVisible(byId('org-settings-link')) - await click(byId('org-settings-link')) - await waitForElementVisible("[data-test='organisation-name']") - - log('Test 2: Create and Delete Organisation, Verify Next Org in Nav') - log('Navigate to create organisation') - await click(byId('home-link')) - await waitForElementVisible(byId('create-organisation-btn')) - await click(byId('create-organisation-btn')) - - log('Create New Organisation') - await waitForElementVisible("[name='orgName']") - await setText("[name='orgName']", 'E2E Test Org to Delete') - await click('#create-org-btn') - - log('Verify New Organisation Created and appears in nav') - await waitForElementVisible(byId('organisation-link')) - await assertTextContent('#organisation-link', 'E2E Test Org to Delete') - - log('Navigate back to the org we want to delete') - await waitForElementVisible(byId('org-settings-link')) - await click(byId('org-settings-link')) - - log('Delete Organisation') - await waitForElementVisible('#delete-org-btn') - await click('#delete-org-btn') - await setText("[name='confirm-org-name']", 'E2E Test Org to Delete') - await clickByText('Confirm') - - log('Verify Redirected to Next Organisation in Nav') - await waitForElementVisible(byId('organisation-link')) - log('Current org in nav after deletion: Test Organisation') - - log('Verify deleted org name does not appear in nav') - const orgLink = Selector('#organisation-link') - await t - .expect(orgLink.textContent) - .notContains( - 'E2E Test Org to Delete', - 'Deleted organisation should not appear in nav', - ) - await assertTextContent('#organisation-link', 'Test Organisation') - - log('Test 3: Cancel Organisation Deletion') - log('Create temporary organisation for cancel test') - await click(byId('home-link')) - await waitForElementVisible(byId('create-organisation-btn')) - await click(byId('create-organisation-btn')) - await waitForElementVisible("[name='orgName']") - await setText("[name='orgName']", 'E2E Cancel Test Org') - await click('#create-org-btn') - - log('Navigate to org settings and open delete modal') - await waitForElementVisible(byId('organisation-link')) - await assertTextContent('#organisation-link', 'E2E Cancel Test Org') - await waitForElementVisible(byId('org-settings-link')) - await click(byId('org-settings-link')) - await waitForElementVisible('#delete-org-btn') - await click('#delete-org-btn') - await waitForElementVisible("[name='confirm-org-name']") - await setText("[name='confirm-org-name']", 'E2E Cancel Test Org') - - log('Close modal without confirming deletion') - await closeModal() - await waitForElementNotExist('.modal') - - log('Verify organisation still exists in navbar') - await waitForElementVisible(byId('organisation-link')) - await assertTextContent('#organisation-link', 'E2E Cancel Test Org') - - log('Clean up: Delete the test organisation') - await click('#delete-org-btn') - await setText("[name='confirm-org-name']", 'E2E Cancel Test Org') - await clickByText('Confirm') - await waitForElementNotExist('.modal') - await waitForElementVisible(byId('organisation-link')) - await assertTextContent('#organisation-link', 'Test Organisation') - - log('Test 4: Organisation Name Validation') - log('Navigate to Test Organisation settings') - await waitForElementVisible(byId('org-settings-link')) - await click(byId('org-settings-link')) - - log('Test empty organisation name validation') - await waitForElementVisible("[data-test='organisation-name']") - const orgNameInput = Selector("[data-test='organisation-name']") - const originalName = await orgNameInput.value - - log('Clear organisation name') - await setText("[data-test='organisation-name']", '') - - log('Verify save button is disabled') - const saveButton = Selector('#save-org-btn') - await t - .expect(saveButton.hasAttribute('disabled')) - .ok('Save button should be disabled with empty name') - - log('Restore original name') - await setText("[data-test='organisation-name']", originalName) - - log('Verify save button is enabled') - await t - .expect(saveButton.hasAttribute('disabled')) - .notOk('Save button should be enabled with valid name') -} diff --git a/frontend/e2e/tests/project-permission-test.pw.ts b/frontend/e2e/tests/project-permission-test.pw.ts new file mode 100644 index 000000000000..b58b21b9ddfe --- /dev/null +++ b/frontend/e2e/tests/project-permission-test.pw.ts @@ -0,0 +1,148 @@ +import { test, expect } from '../test-setup'; +import { + byId, + click, + clickFeatureAction, + createEnvironment, + createFeature, + getFeatureIndexByName, + gotoSegments, + log, + login, + logout, + setUserPermission, + toggleFeature, + waitForElementNotClickable, + waitForElementNotExist, + waitForElementVisible, + waitForFeatureSwitch, + waitForPageFullyLoaded, + createHelpers, +} from '../helpers.playwright'; +import { E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, E2E_USER, PASSWORD } from '../config'; + +test.describe('Project Permission Tests', () => { + test('test description @enterprise', async ({ page }) => { + const helpers = createHelpers(page); + + log('User with VIEW_PROJECT can only see their project') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.waitForElementNotExist('#project-select-1') + await helpers.logout() + + log('User with CREATE_ENVIRONMENT can create an environment') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await createEnvironment(page, 'Staging') + await helpers.logout() + + log('User with VIEW_AUDIT_LOG can view the audit log') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.click(byId('audit-log-link')) + await helpers.logout() + log('Remove VIEW_AUDIT_LOG permission') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'VIEW_AUDIT_LOG', 'My Test Project 5 Project Permission', 'project' ) + await helpers.logout() + log('User without VIEW_AUDIT_LOG cannot view the audit log') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.waitForElementNotExist('audit-log-link') + await helpers.logout() + + log('User with CREATE_FEATURE can Handle the Features') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await createFeature(page, 0, 'test_feature', false) + await toggleFeature(page, 'test_feature', true) + await helpers.logout() + log('Remove CREATE_FEATURE permissions') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'CREATE_FEATURE', 'My Test Project 5 Project Permission', 'project' ) + await helpers.logout() + log('User without CREATE_FEATURE cannot Handle the Features') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await waitForElementNotClickable(page, '#show-create-feature-btn') + await helpers.logout() + + log('User without ADMIN permissions cannot set other users project permissions') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.waitForElementNotExist('#project-settings-link') + await helpers.logout() + + log('Set user as project ADMIN') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'ADMIN', 'My Test Project 5 Project Permission', 'project' ) + await helpers.logout() + log('User with ADMIN permissions can set project settings') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.waitForElementVisible('#project-settings-link') + await helpers.logout() + log('Remove user as project ADMIN') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'ADMIN', 'My Test Project 5 Project Permission', 'project' ) + await helpers.logout() + + log('User without create environment permissions cannot create a new environment') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'CREATE_ENVIRONMENT', 'My Test Project 5 Project Permission', 'project' ) + await helpers.logout() + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.waitForElementNotExist('#create-env-link') + await helpers.logout() + + log('User without DELETE_FEATURE permissions cannot Delete any feature') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await waitForPageFullyLoaded(page) + await waitForElementVisible(page, '#features-page') + await waitForFeatureSwitch(page, 'test_feature', 'on') + await clickFeatureAction(page, 'test_feature') + const featureIndex = await getFeatureIndexByName(page, 'test_feature') + await helpers.waitForElementVisible(byId(`feature-remove-${featureIndex}`)) + await expect(page.locator(byId(`feature-remove-${featureIndex}`))).toHaveClass( + /feature-action__item_disabled/, + ) + await helpers.logout() + log('Add DELETE_FEATURE permission to user') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'DELETE_FEATURE', 'My Test Project 5 Project Permission', 'project' ) + await helpers.logout() + log('User with permissions can Delete any feature') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await clickFeatureAction(page, 'test_feature') + const featureIndex2 = await getFeatureIndexByName(page, 'test_feature') + await helpers.waitForElementVisible(byId(`feature-remove-${featureIndex2}`)) + await expect(page.locator(byId(`feature-remove-${featureIndex2}`))).not.toHaveClass( + /feature-action__item_disabled/, + ) + await helpers.logout() + + log('User without MANAGE_SEGMENTS permissions cannot Manage Segments') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.gotoSegments() + const createSegmentBtn = page.locator(byId('show-create-segment-btn')) + await expect(createSegmentBtn).toBeDisabled() + await helpers.logout() + log('Add MANAGE_SEGMENTS permission to user') + await helpers.login(E2E_USER, PASSWORD) + await setUserPermission(page, + E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, + 'MANAGE_SEGMENTS', + 'My Test Project 5 Project Permission', + 'project' + ) + await helpers.logout() + log('User with MANAGE_SEGMENTS permissions can Manage Segments') + await helpers.login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await helpers.click('#project-select-0') + await helpers.gotoSegments() + await expect(createSegmentBtn).not.toBeDisabled() + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/project-permission-test.ts b/frontend/e2e/tests/project-permission-test.ts deleted file mode 100644 index f6c83e84233e..000000000000 --- a/frontend/e2e/tests/project-permission-test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { - byId, - click, - createEnvironment, - createFeature, gotoSegments, - log, - login, - logout, - setUserPermission, - toggleFeature, waitForElementNotClickable, waitForElementNotExist, waitForElementVisible, -} from '../helpers.cafe'; -import { E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, E2E_USER, PASSWORD } from '../config'; -import { Selector, t } from 'testcafe'; - -export default async function () { - - log('User with VIEW_PROJECT can only see their project') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await waitForElementNotExist('#project-select-1') - await logout() - - log('User with CREATE_ENVIRONMENT can create an environment') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await createEnvironment('Staging') - await logout() - - log('User with VIEW_AUDIT_LOG can view the audit log') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('audit-log-link')) - await logout() - log('Remove VIEW_AUDIT_LOG permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'VIEW_AUDIT_LOG', 'My Test Project 5 Project Permission', 'project' ) - await logout() - log('User without VIEW_AUDIT_LOG cannot view the audit log') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementNotExist('audit-log-link') - await logout() - - log('User with CREATE_FEATURE can Handle the Features') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await createFeature(0, 'test_feature', false) - await toggleFeature(0, true) - await logout() - log('Remove CREATE_FEATURE permissions') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'CREATE_FEATURE', 'My Test Project 5 Project Permission', 'project' ) - await logout() - log('User without CREATE_FEATURE cannot Handle the Features') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementNotClickable('#show-create-feature-btn') - await logout() - - log('User without ADMIN permissions cannot set other users project permissions') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await waitForElementNotExist('#project-settings-link') - await logout() - - log('Set user as project ADMIN') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'ADMIN', 'My Test Project 5 Project Permission', 'project' ) - await logout() - log('User with ADMIN permissions can set project settings') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementVisible('#project-settings-link') - await logout() - log('Remove user as project ADMIN') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'ADMIN', 'My Test Project 5 Project Permission', 'project' ) - await logout() - - log('User without create environment permissions cannot create a new environment') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'CREATE_ENVIRONMENT', 'My Test Project 5 Project Permission', 'project' ) - await logout() - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementNotExist('#create-env-link') - await logout() - - log('User without DELETE_FEATURE permissions cannot Delete any feature') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('feature-action-0')) - await waitForElementVisible(byId('feature-remove-0')) - await Selector(byId('feature-remove-0')).hasClass( - 'feature-action__item_disabled', - ) - await logout() - log('Add DELETE_FEATURE permission to user') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'DELETE_FEATURE', 'My Test Project 5 Project Permission', 'project' ) - await logout() - log('User with permissions can Delete any feature') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('feature-action-0')) - await waitForElementVisible(byId('feature-remove-0')) - await t.expect(Selector(byId('feature-remove-0')).hasClass('feature-action__item_disabled')).notOk(); - await logout() - - log('User without MANAGE_SEGMENTS permissions cannot Manage Segments') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await gotoSegments() - const createSegmentBtn = Selector(byId('show-create-segment-btn')) - await t.expect(createSegmentBtn.hasAttribute('disabled')).ok() - await logout() - log('Add MANAGE_SEGMENTS permission to user') - await login(E2E_USER, PASSWORD) - await setUserPermission( - E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, - 'MANAGE_SEGMENTS', - 'My Test Project 5 Project Permission', - 'project' - ) - await logout() - log('User with MANAGE_SEGMENTS permissions can Manage Segments') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await gotoSegments() - await t.expect(createSegmentBtn.hasAttribute('disabled')).notOk() -} diff --git a/frontend/e2e/tests/project-test.pw.ts b/frontend/e2e/tests/project-test.pw.ts new file mode 100644 index 000000000000..284f5e6dc0f9 --- /dev/null +++ b/frontend/e2e/tests/project-test.pw.ts @@ -0,0 +1,75 @@ +import { test, expect } from '../test-setup'; +import { + assertInputValue, + assertTextContent, + byId, + click, + getFlagsmith, + log, + login, + setText, + waitForElementNotExist, + waitForElementVisible, + createHelpers, +} from '../helpers.playwright'; +import { E2E_USER, PASSWORD } from '../config' + +test.describe('Project Tests', () => { + test('test description @oss', async ({ page }) => { + const helpers = createHelpers(page); + const flagsmith = await getFlagsmith() + const hasSegmentChangeRequests = flagsmith.hasFeature('segment_change_requests') + + log('Login') + await helpers.login(E2E_USER, PASSWORD) + await helpers.click('#project-select-0') + log('Edit Project') + await helpers.click('#project-link') + await helpers.click('#project-settings-link') + await helpers.setText("[name='proj-name']", 'Test Project') + await helpers.click('#save-proj-btn') + await assertTextContent(page, `#project-link`, 'Test Project') + + if (hasSegmentChangeRequests) { + log('Test Change Requests Approvals Setting') + + log('Test 1: Enable change requests (auto-save on toggle)') + await helpers.click('[data-test="js-change-request-approvals"]') + await helpers.waitForElementVisible('[name="env-name"]') + log('Verify auto-save persisted after navigation') + await helpers.click('#features-link') + await helpers.click('#project-settings-link') + await helpers.waitForElementVisible('[name="env-name"]') + + log('Test 2: Change minimum approvals to 3 (manual save)') + await helpers.setText('[name="env-name"]', '3') + await helpers.click('#save-env-btn') + // Wait for save to complete + await page.waitForTimeout(1000) + log('Verify value 3 persisted after navigation') + await helpers.click('#features-link') + await helpers.click('#project-settings-link') + await helpers.waitForElementVisible('[name="env-name"]') + await assertInputValue(page, '[name="env-name"]', '3') + + log('Test 3: Disable change requests (auto-save on toggle)') + await helpers.click('[data-test="js-change-request-approvals"]') + log('Verify disabled state persisted after navigation') + await helpers.click('#features-link') + await helpers.click('#project-settings-link') + await helpers.waitForElementNotExist('[name="env-name"]') + + log('Test 4: Re-enable and change to 5 (manual save)') + await helpers.click('[data-test="js-change-request-approvals"]') + await helpers.waitForElementVisible('[name="env-name"]') + await helpers.setText('[name="env-name"]', '5') + await helpers.click('#save-env-btn') + log('Verify value 5 persisted after navigation') + await helpers.click('#features-link') + await helpers.click('#project-settings-link') + await helpers.waitForElementVisible('[name="env-name"]') + await assertInputValue(page, '[name="env-name"]', '5') + } + + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/project-test.ts b/frontend/e2e/tests/project-test.ts deleted file mode 100644 index eeda1c0f01c9..000000000000 --- a/frontend/e2e/tests/project-test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - assertInputValue, - assertTextContent, - byId, - click, - getFlagsmith, - log, - login, - setText, - waitForElementNotExist, - waitForElementVisible, -} from '../helpers.cafe'; -import { E2E_USER, PASSWORD } from '../config' - -export default async function () { - const flagsmith = await getFlagsmith() - const hasSegmentChangeRequests = flagsmith.hasFeature('segment_change_requests') - - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-0') - log('Edit Project') - await click('#project-link') - await click('#project-settings-link') - await setText("[name='proj-name']", 'Test Project') - await click('#save-proj-btn') - await assertTextContent(`#project-link`, 'Test Project') - - if (hasSegmentChangeRequests) { - log('Test Change Requests Approvals Setting') - - log('Test 1: Enable change requests (auto-save on toggle)') - await click('[data-test="js-change-request-approvals"]') - await waitForElementVisible('[name="env-name"]') - log('Verify auto-save persisted after navigation') - await click('#features-link') - await click('#project-settings-link') - await waitForElementVisible('[name="env-name"]') - - log('Test 2: Change minimum approvals to 3 (manual save)') - await setText('[name="env-name"]', '3') - await click('#save-env-btn') - log('Verify value 3 persisted after navigation') - await click('#features-link') - await click('#project-settings-link') - await waitForElementVisible('[name="env-name"]') - await assertInputValue('[name="env-name"]', '3') - - log('Test 3: Disable change requests (auto-save on toggle)') - await click('[data-test="js-change-request-approvals"]') - log('Verify disabled state persisted after navigation') - await click('#features-link') - await click('#project-settings-link') - await waitForElementNotExist('[name="env-name"]') - - log('Test 4: Re-enable and change to 5 (manual save)') - await click('[data-test="js-change-request-approvals"]') - await waitForElementVisible('[name="env-name"]') - await setText('[name="env-name"]', '5') - await click('#save-env-btn') - log('Verify value 5 persisted after navigation') - await click('#features-link') - await click('#project-settings-link') - await waitForElementVisible('[name="env-name"]') - await assertInputValue('[name="env-name"]', '5') - } - -} diff --git a/frontend/e2e/tests/roles-test.pw.ts b/frontend/e2e/tests/roles-test.pw.ts new file mode 100644 index 000000000000..f5c5c84b49b1 --- /dev/null +++ b/frontend/e2e/tests/roles-test.pw.ts @@ -0,0 +1,66 @@ +import { test, expect } from '../test-setup'; +import { + byId, + click, + createFeature, + log, + login, + setText, + waitForElementVisible, + closeModal, + logout, + gotoTraits, + deleteFeature, createRole, + createHelpers, +} from '../helpers.playwright'; +import { + PASSWORD, + E2E_NON_ADMIN_USER_WITH_A_ROLE, + E2E_USER, +} from '../config' + +test.describe('Roles Tests', () => { + test('test description @enterprise', async ({ page }) => { + const helpers = createHelpers(page); + const rolesProject = 'project-my-test-project-7-role' + log('Login') + await helpers.login(E2E_USER, PASSWORD) + await helpers.click(byId(rolesProject)) + await createFeature(page, 0, 'test_feature', false) + log('Go to Roles') + await helpers.click(byId('organisation-link')) + await helpers.click(byId('users-and-permissions')) + await helpers.waitForElementVisible(byId('tab-item-roles')) + log('Create Role') + await createRole(page, 'test_role', 0, [4]) + log('Add project permissions to the Role') + await helpers.click(byId(`role-0`)) + await helpers.click(byId('permissions-tab')) + await helpers.click(byId('permissions-tab')) + await helpers.waitForElementVisible(byId('project-permissions-tab')) + await helpers.click(byId('project-permissions-tab')) + await helpers.click(byId('permissions-my test project 7 role')) + await helpers.click(byId('admin-switch-project')) + log('Add environment permissions to the Role') + await helpers.waitForElementVisible(byId('environment-permissions-tab')) + await helpers.click(byId('environment-permissions-tab')) + await helpers.click(byId('project-select')) + await helpers.waitForElementVisible(byId('project-select-option-6')) + await helpers.click(byId('project-select-option-6')) + await helpers.click(byId('permissions-development')) + await helpers.click(byId('admin-switch-environment')) + await closeModal(page) + await helpers.logout() + log('Login with the user with a new Role') + await page.evaluate(() => location.reload()); + await page.waitForTimeout(2000); + await helpers.login(E2E_NON_ADMIN_USER_WITH_A_ROLE, PASSWORD) + await helpers.click(byId(rolesProject)) + log('User with permissions can Handle the Features') + const flagName = 'test_feature' + await deleteFeature(page, flagName) + + log('User with permissions can See the Identities') + await gotoTraits(page) + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/roles-test.ts b/frontend/e2e/tests/roles-test.ts deleted file mode 100644 index b85aab1609fe..000000000000 --- a/frontend/e2e/tests/roles-test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - byId, - click, - createFeature, - log, - login, - setText, - waitForElementVisible, - closeModal, - logout, - gotoTraits, - deleteFeature, createRole, -} from '../helpers.cafe'; -import { - PASSWORD, - E2E_NON_ADMIN_USER_WITH_A_ROLE, - E2E_USER, -} from '../config' -import { t } from 'testcafe' - -export default async function () { - const rolesProject = 'project-my-test-project-7-role' - log('Login') - await login(E2E_USER, PASSWORD) - await click(byId(rolesProject)) - await createFeature(0, 'test_feature', false) - log('Go to Roles') - await click(byId('organisation-link')) - await click(byId('users-and-permissions')) - await waitForElementVisible(byId('tab-item-roles')) - log('Create Role') - await createRole('test_role', 0, [4]) - log('Add project permissions to the Role') - await click(byId(`role-0`)) - await click(byId('permissions-tab')) - await click(byId('permissions-tab')) - await waitForElementVisible(byId('project-permissions-tab')) - await click(byId('project-permissions-tab')) - await click(byId('permissions-my test project 7 role')) - await click(byId('admin-switch-project')) - log('Add environment permissions to the Role') - await waitForElementVisible(byId('environment-permissions-tab')) - await click(byId('environment-permissions-tab')) - await click(byId('project-select')) - await waitForElementVisible(byId('project-select-option-6')) - await click(byId('project-select-option-6')) - await click(byId('permissions-development')) - await click(byId('admin-switch-environment')) - await closeModal() - await logout(t) - log('Login with the user with a new Role') - await t.eval(() => location.reload()); - await t.wait(2000); - await login(E2E_NON_ADMIN_USER_WITH_A_ROLE, PASSWORD) - await click(byId(rolesProject)) - log('User with permissions can Handle the Features') - const flagName = 'test_feature' - await deleteFeature(0, flagName) - - log('User with permissions can See the Identities') - await gotoTraits() -} diff --git a/frontend/e2e/tests/segment-test.pw.ts b/frontend/e2e/tests/segment-test.pw.ts new file mode 100644 index 000000000000..7f81f3cb83b0 --- /dev/null +++ b/frontend/e2e/tests/segment-test.pw.ts @@ -0,0 +1,297 @@ +import { test, expect } from '../test-setup'; +import { + addSegmentOverride, + addSegmentOverrideConfig, + assertTextContent, + assertUserFeatureValue, + byId, + clickUserFeature, + clickUserFeatureSwitch, + closeModal, + createFeature, + createRemoteConfig, + createSegment, + createTrait, + deleteFeature, + deleteTrait, + deleteSegment, + gotoFeature, + gotoTraits, + goToUser, + log, + saveFeatureSegments, + setSegmentOverrideIndex, + waitAndRefresh, + waitForUserFeatureSwitch, + cloneSegment, + setSegmentRule, + assertInputValue, + deleteSegmentFromPage, + createHelpers, +} from '../helpers.playwright'; +import { E2E_USER, PASSWORD } from '../config' + +// Keep the last rule simple to facilitate update testing +const segmentRules = [ + // rule 2 =18 || =17 + { + name: 'age', + operator: 'EQUAL', + ors: [ + { + name: 'age', + operator: 'EQUAL', + value: 17, + }, + ], + value: 18, + }, + //rule 2 >17 or <10 + { + name: 'age', + operator: 'GREATER_THAN', + ors: [ + { + name: 'age', + operator: 'LESS_THAN', + value: 10, + }, + ], + value: 17, + }, + // rule 3 !=20 + { + name: 'age', + operator: 'NOT_EQUAL', + value: 20, + }, + // Rule 4 <= 18 + { + name: 'age', + operator: 'LESS_THAN_INCLUSIVE', + value: 18, + }, + // Rule 5 >= 18 + { + name: 'age', + operator: 'GREATER_THAN_INCLUSIVE', + value: 18, + }, +] + +test('Segment test 1 - Create, update, and manage segments with multivariate flags @oss', async ({ page }) => { + const helpers = createHelpers(page) + + log('Login') + await helpers.login(E2E_USER, PASSWORD) + await helpers.click('#project-select-1') + await helpers.waitForElementVisible(byId('features-page')) + + log('Create Feature') + + await createRemoteConfig(page, 0, 'mv_flag', 'big', null, null, [ + { value: 'medium', weight: 100 }, + { value: 'small', weight: 0 }, + ]) + + await helpers.gotoSegments() + + log('Segment age rules') + // (=== 18 || === 19) && (> 17 || < 19) && (!=20) && (<=18) && (>=18) + // Rule 1- Age === 18 || Age === 19 + + log('Update segment') + await helpers.gotoSegments() + const lastRule = segmentRules[segmentRules.length - 1] + await createSegment(page, 0, 'segment_to_update', [lastRule]) + await helpers.navigateToSegment('segment_to_update') + await setSegmentRule(page, 0, 0, lastRule.name, lastRule.operator, lastRule.value + 1) + await helpers.click(byId('update-segment')) + log('Check segment rule value') + await helpers.gotoSegments() + await helpers.navigateToSegment('segment_to_update') + await assertInputValue(page, byId(`rule-${0}-value-0`), `${lastRule.value + 1}`) + await deleteSegmentFromPage(page, 'segment_to_update') + + log('Create segment') + await createSegment(page, 0, '18_or_19', segmentRules) + + log('Add segment trait for user') + await gotoTraits(page) + await createTrait(page, 'age', 18) + + // Wait for trait to be applied and feature values to load + await waitAndRefresh(page) + await assertUserFeatureValue(page, 'mv_flag', '"medium"') + await helpers.gotoFeatures() + await gotoFeature(page, 'mv_flag') + + await addSegmentOverride(page, 0, true, 0, [ + { value: 'medium', weight: 0 }, + { value: 'small', weight: 100 }, + ]) + + await helpers.click('#update-feature-segments-btn') + + // Wait for success message to appear indicating save completed + await page.waitForSelector('.toast-message', { state: 'visible', timeout: 10000 }) + + // Wait for toast to disappear + await page.waitForSelector('.toast-message', { state: 'hidden', timeout: 10000 }) + + await closeModal(page) + + await gotoTraits(page) + await waitAndRefresh(page) + + await assertUserFeatureValue(page, 'mv_flag', '"small"') + + // log('Check user now belongs to segment'); + const segmentElement = page.locator('[data-test^="segment-"][data-test$="-name"]').filter({ hasText: '18_or_19' }); + await expect(segmentElement).toBeVisible() + + // log('Delete segment trait for user'); + await deleteTrait(page, 'age') + + log('Set user MV override') + await clickUserFeature(page, 'mv_flag') + await helpers.click(byId('select-variation-medium')) + await helpers.click(byId('update-feature-btn')) + await waitAndRefresh(page) + await assertUserFeatureValue(page, 'mv_flag', '"medium"') + + log('Clone segment') + await helpers.gotoSegments() + await cloneSegment(page, 0, '0cloned-segment') + await deleteSegment(page, 0, '0cloned-segment') + + log('Delete segment') + await helpers.gotoSegments() + await deleteSegment(page, 0, '18_or_19') + await helpers.gotoFeatures() + await deleteFeature(page, 'mv_flag') +}) + +test('Segment test 2 - Test segment priority and overrides @oss', async ({ page }) => { + const helpers = createHelpers(page) + + log('Login') + await helpers.login(E2E_USER, PASSWORD) + await helpers.click('#project-select-2') + await helpers.waitForElementVisible(byId('features-page')) + + log('Create segments') + await helpers.gotoSegments() + await createSegment(page, 0, 'segment_1', [ + { + name: 'trait', + operator: 'EQUAL', + value: '1', + }, + ]) + await createSegment(page, 1, 'segment_2', [ + { + name: 'trait2', + operator: 'EQUAL', + value: '2', + }, + ]) + await createSegment(page, 2, 'segment_3', [ + { + name: 'trait3', + operator: 'EQUAL', + value: '3', + }, + ]) + + log('Create Features') + await helpers.gotoFeatures() + await createFeature(page, 0, 'flag') + await createRemoteConfig(page, 0, 'config', 0) + + log('Set segment overrides features') + await gotoFeature(page, 'config') + await addSegmentOverrideConfig(page, 0, 1, 0) + await addSegmentOverrideConfig(page, 1, 2, 0) + await addSegmentOverrideConfig(page, 2, 3, 0) + await saveFeatureSegments(page) + await gotoFeature(page, 'flag') + await addSegmentOverride(page, 0, true, 0) + await addSegmentOverride(page, 1, false, 0) + await addSegmentOverride(page, 2, true, 0) + await saveFeatureSegments(page) + + log('Set user in segment_1') + await goToUser(page, 0) + await createTrait(page, 'trait', 1) + await createTrait(page, 'trait2', 2) + await createTrait(page, 'trait3', 3) + // await assertTextContent(page, byId('segment-0-name'), 'segment_1'); todo: view user segments disabled in edge + await waitForUserFeatureSwitch(page, 'flag', 'on') + // Wait for feature values to update after trait creation + await page.waitForTimeout(1000) + await assertUserFeatureValue(page, 'config', '1') + + log('Prioritise segment 2') + await helpers.gotoFeatures() + await gotoFeature(page, 'config') + await setSegmentOverrideIndex(page, 1, 0) + await saveFeatureSegments(page) + await gotoFeature(page, 'flag') + await setSegmentOverrideIndex(page, 1, 0) + await saveFeatureSegments(page) + await goToUser(page, 0) + await waitForUserFeatureSwitch(page, 'flag', 'off') + await assertUserFeatureValue(page, 'config', '2') + + log('Prioritise segment 3') + await helpers.gotoFeatures() + await gotoFeature(page, 'config') + await setSegmentOverrideIndex(page, 2, 0) + await saveFeatureSegments(page) + await gotoFeature(page, 'flag') + await setSegmentOverrideIndex(page, 2, 0) + await saveFeatureSegments(page) + await goToUser(page, 0) + await waitForUserFeatureSwitch(page, 'flag', 'on') + await assertUserFeatureValue(page, 'config', '3') + + log('Clear down features') + await helpers.gotoFeatures() + await deleteFeature(page, 'flag') + await deleteFeature(page, 'config') +}) + +test('Segment test 3 - Test user-specific feature overrides @oss', async ({ page }) => { + const helpers = createHelpers(page) + + log('Login') + await helpers.login(E2E_USER, PASSWORD) + await helpers.click('#project-select-3') + await helpers.waitForElementVisible(byId('features-page')) + + log('Create features') + await helpers.gotoFeatures() + await createFeature(page, 0, 'flag', true) + await createRemoteConfig(page, 0, 'config', 0, 'Description') + + log('Toggle flag for user') + await goToUser(page, 0) + await clickUserFeatureSwitch(page, 'flag', 'on') + await helpers.click('#confirm-toggle-feature-btn') + await waitAndRefresh(page) // wait and refresh to avoid issues with data sync from UK -> US in github workflows + await waitForUserFeatureSwitch(page, 'flag', 'off') + + log('Edit flag for user') + await clickUserFeature(page, 'config') + await helpers.setText(byId('featureValue'), 'small') + await helpers.click('#update-feature-btn') + await waitAndRefresh(page) // wait and refresh to avoid issues with data sync from UK -> US in github workflows + await assertUserFeatureValue(page, 'config', '"small"') + + log('Toggle flag for user again') + await clickUserFeatureSwitch(page, 'flag', 'off'); + await helpers.click('#confirm-toggle-feature-btn'); + await waitAndRefresh(page); // wait and refresh to avoid issues with data sync from UK -> US in github workflows + await waitForUserFeatureSwitch(page, 'flag', 'on'); +}) diff --git a/frontend/e2e/tests/segment-test.ts b/frontend/e2e/tests/segment-test.ts deleted file mode 100644 index 916cb257af27..000000000000 --- a/frontend/e2e/tests/segment-test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { - addSegmentOverride, - addSegmentOverrideConfig, - assertTextContent, - byId, - click, - closeModal, - createFeature, - createRemoteConfig, - createSegment, - createTrait, - deleteFeature, - deleteTrait, - deleteSegment, - gotoFeature, - gotoFeatures, - gotoSegments, - gotoTraits, - goToUser, - log, - login, - saveFeatureSegments, - setSegmentOverrideIndex, - setText, - viewFeature, - waitAndRefresh, - waitForElementVisible, - cloneSegment, - setSegmentRule, - assertInputValue, - clickSegmentByName, deleteSegmentFromPage, -} from '../helpers.cafe'; -import { E2E_USER, PASSWORD } from '../config' - -// Keep the last rule simple to facilitate update testing -const segmentRules = [ - // rule 2 =18 || =17 - { - name: 'age', - operator: 'EQUAL', - ors: [ - { - name: 'age', - operator: 'EQUAL', - value: 17, - }, - ], - value: 18, - }, - //rule 2 >17 or <10 - { - name: 'age', - operator: 'GREATER_THAN', - ors: [ - { - name: 'age', - operator: 'LESS_THAN', - value: 10, - }, - ], - value: 17, - }, - // rule 3 !=20 - { - name: 'age', - operator: 'NOT_EQUAL', - value: 20, - }, - // Rule 4 <= 18 - { - name: 'age', - operator: 'LESS_THAN_INCLUSIVE', - value: 18, - }, - // Rule 5 >= 18 - { - name: 'age', - operator: 'GREATER_THAN_INCLUSIVE', - value: 18, - }, -] - -export const testSegment1 = async (flagsmith: any) => { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-1') - log('Create Feature') - - await createRemoteConfig(0, 'mv_flag', 'big', null, null, [ - { value: 'medium', weight: 100 }, - { value: 'small', weight: 0 }, - ]) - - await gotoSegments() - - log('Segment age rules') - // (=== 18 || === 19) && (> 17 || < 19) && (!=20) && (<=18) && (>=18) - // Rule 1- Age === 18 || Age === 19 - - log('Update segment') - await gotoSegments() - const lastRule = segmentRules[segmentRules.length - 1] - await createSegment(0, 'segment_to_update', [lastRule]) - await click(byId('segment-0-name')) - await setSegmentRule(0, 0, lastRule.name, lastRule.operator, lastRule.value + 1) - await click(byId('update-segment')) - log('Check segment rule value') - await gotoSegments() - await click(byId('segment-0-name')) - await assertInputValue(byId(`rule-${0}-value-0`), `${lastRule.value + 1}`) - await deleteSegmentFromPage('segment_to_update') - - log('Create segment') - await createSegment(0, '18_or_19', segmentRules) - - - log('Add segment trait for user') - await gotoTraits() - await createTrait(0, 'age', 18) - - await assertTextContent(byId('user-feature-value-0'), '"medium"') - await gotoFeatures() - await gotoFeature(0) - - await addSegmentOverride(0, true, 0, [ - { value: 'medium', weight: 0 }, - { value: 'small', weight: 100 }, - ]) - await click('#update-feature-segments-btn') - await closeModal() - await waitAndRefresh() - - await gotoTraits() - await assertTextContent(byId('user-feature-value-0'), '"small"') - - // log('Check user now belongs to segment'); - await assertTextContent(byId('segment-0-name'), '18_or_19') - - // log('Delete segment trait for user'); - await deleteTrait(0) - - log('Set user MV override') - await click(byId('user-feature-0')) - await click(byId('select-variation-medium')) - await click(byId('update-feature-btn')) - await waitAndRefresh() - await assertTextContent(byId('user-feature-value-0'), '"medium"') - - log('Clone segment') - await gotoSegments() - await cloneSegment(0, '0cloned-segment') - await deleteSegment(0, '0cloned-segment') - - log('Delete segment') - await gotoSegments() - await deleteSegment(0, '18_or_19') - await gotoFeatures() - await deleteFeature(0, 'mv_flag') -} - -export const testSegment2 = async () => { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-2') - - log('Create segments') - await gotoSegments() - await createSegment(0, 'segment_1', [ - { - name: 'trait', - operator: 'EQUAL', - value: '1', - }, - ]) - await createSegment(1, 'segment_2', [ - { - name: 'trait2', - operator: 'EQUAL', - value: '2', - }, - ]) - await createSegment(2, 'segment_3', [ - { - name: 'trait3', - operator: 'EQUAL', - value: '3', - }, - ]) - - log('Create Features') - await gotoFeatures() - await createFeature(0, 'flag') - await createRemoteConfig(0, 'config', 0) - - log('Set segment overrides features') - await viewFeature(0) - await addSegmentOverrideConfig(0, 1, 0) - await addSegmentOverrideConfig(1, 2, 0) - await addSegmentOverrideConfig(2, 3, 0) - await saveFeatureSegments() - await viewFeature(1) - await addSegmentOverride(0, true, 0) - await addSegmentOverride(1, false, 0) - await addSegmentOverride(2, true, 0) - await saveFeatureSegments() - - log('Set user in segment_1') - await goToUser(0) - await createTrait(0, 'trait', 1) - await createTrait(1, 'trait2', 2) - await createTrait(2, 'trait3', 3) - // await assertTextContent(byId('segment-0-name'), 'segment_1'); todo: view user segments disabled in edge - await waitForElementVisible(byId('user-feature-switch-1-on')) - await assertTextContent(byId('user-feature-value-0'), '1') - - log('Prioritise segment 2') - await gotoFeatures() - await gotoFeature(0) - await setSegmentOverrideIndex(1, 0) - await saveFeatureSegments() - await gotoFeature(1) - await setSegmentOverrideIndex(1, 0) - await saveFeatureSegments() - await goToUser(0) - await waitForElementVisible(byId('user-feature-switch-1-off')) - await assertTextContent(byId('user-feature-value-0'), '2') - - log('Prioritise segment 3') - await gotoFeatures() - await gotoFeature(0) - await setSegmentOverrideIndex(2, 0) - await saveFeatureSegments() - await gotoFeature(1) - await setSegmentOverrideIndex(2, 0) - await saveFeatureSegments() - await goToUser(0) - await waitForElementVisible(byId('user-feature-switch-1-on')) - await assertTextContent(byId('user-feature-value-0'), '3') - - log('Clear down features') - await gotoFeatures() - await deleteFeature(1, 'flag') - await deleteFeature(0, 'config') -} - -export const testSegment3 = async () => { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-3') - - log('Create features') - await gotoFeatures() - await createFeature(0, 'flag', true) - await createRemoteConfig(0, 'config', 0, 'Description') - - log('Toggle flag for user') - await goToUser(0) - await click(byId('user-feature-switch-1-on')) - await click('#confirm-toggle-feature-btn') - await waitAndRefresh() // wait and refresh to avoid issues with data sync from UK -> US in github workflows - await waitForElementVisible(byId('user-feature-switch-1-off')) - - log('Edit flag for user') - await click(byId('user-feature-0')) - await setText(byId('featureValue'), 'small') - await click('#update-feature-btn') - await waitAndRefresh() // wait and refresh to avoid issues with data sync from UK -> US in github workflows - await assertTextContent(byId('user-feature-value-0'), '"small"') - - log('Toggle flag for user again') - await click(byId('user-feature-switch-1-off')) - await click('#confirm-toggle-feature-btn') - await waitAndRefresh() // wait and refresh to avoid issues with data sync from UK -> US in github workflows - await waitForElementVisible(byId('user-feature-switch-1-on')) -} diff --git a/frontend/e2e/tests/versioning-tests.pw.ts b/frontend/e2e/tests/versioning-tests.pw.ts new file mode 100644 index 000000000000..00439fe03a05 --- /dev/null +++ b/frontend/e2e/tests/versioning-tests.pw.ts @@ -0,0 +1,133 @@ +import { test, expect } from '../test-setup'; +import { + assertNumberOfVersions, + byId, + compareVersion, + createFeature, + createOrganisationAndProject, + createRemoteConfig, + editRemoteConfig, + getFlagsmith, + getFeatureIndexByName, + log, + parseTryItResults, + toggleFeature, + waitForFeatureSwitch, + createHelpers, +} from '../helpers.playwright'; +import { E2E_USER, PASSWORD } from '../config'; + +test('Versioning tests - Create, edit, and compare feature versions @oss', async ({ page }) => { + const helpers = createHelpers(page) + const flagsmith = await getFlagsmith() + const hasFeature = flagsmith.hasFeature("feature_versioning") + + log('Login') + await helpers.login(E2E_USER, PASSWORD) + + if(!hasFeature) { + log("Skipping version test, feature not enabled.") + test.skip() + return + } + + await createOrganisationAndProject(page, 'Flagsmith Versioning Org', 'Flagsmith Versioning Project') + await helpers.waitForElementVisible(byId('features-page')) + await helpers.click('#env-settings-link') + await helpers.click(byId('enable-versioning')) + await helpers.click('#confirm-btn-yes') + // Feature versioning takes up to a minute to enable on the backend + await helpers.waitForElementVisible(byId('feature-versioning-enabled'), 70000) + + log('Create feature 1') + await createRemoteConfig(page, 0, 'a', 'small') + log('Edit feature 1') + await editRemoteConfig(page, 'a','medium') + + log('Create feature 2') + await createRemoteConfig(page, 1, 'b', 'small', null, null, [ + { value: 'medium', weight: 100 }, + { value: 'big', weight: 0 }, + ]) + log('Edit feature 2') + await editRemoteConfig(page, 'b','small',false,[ + { value: 'medium', weight: 0 }, + { value: 'big', weight: 100 }, + ]) + + log('Create feature 3') + await createFeature(page, 2, 'c', false) + log('Edit feature 3') + await editRemoteConfig(page, 'c','',true) + + log('Assert version counts') + await assertNumberOfVersions(page, 'a', 2) + await assertNumberOfVersions(page, 'b', 2) + await assertNumberOfVersions(page, 'c', 2) + await compareVersion(page, 'a',0,null,true,true, 'small','medium') + await compareVersion(page, 'b',0,null,true,true, 'small','small') + await compareVersion(page, 'c',0,null,false,true, null,null) + + // =================================================================================== + // Test: Row toggle in versioned environment + // This tests that toggling a feature via the row switch works when Feature Versioning + // is enabled. The toggle must use the versioning API instead of the regular PUT. + // We reuse the existing versioned environment from the tests above. + // Note: Feature 'c' is currently ON after editRemoteConfig(page, 2,'',true) above. + // =================================================================================== + log('Test row toggle in versioned environment') + + // Feature 'c' is currently ON - toggle it OFF + log('Toggle feature OFF via row switch (versioned env)') + await toggleFeature(page, 'c', false) + + // Verify: Switch shows OFF state on features list + await waitForFeatureSwitch(page, 'c', 'off') + + // Verify: API returns correct state (feature disabled) + log('Verify API returns disabled state') + await page.waitForTimeout(500) + await helpers.click('#try-it-btn') + await page.waitForTimeout(500) + let json = await parseTryItResults(page) + expect(json.c.enabled).toBe(false) + + // Refresh page to verify state was persisted to backend + log('Refresh page to verify toggle OFF persisted') + await page.reload() + await helpers.waitForElementVisible(byId('features-page')) + await waitForFeatureSwitch(page, 'c', 'off') + + // Toggle feature 'c' back ON using row switch + log('Toggle feature ON via row switch (versioned env)') + await toggleFeature(page, 'c', true) + + // Verify: Switch shows ON state on features list + await waitForFeatureSwitch(page, 'c', 'on') + + // Verify: API returns correct state (feature enabled) + log('Verify API returns enabled state') + // In versioned environments, changes may take MUCH longer to propagate to the edge API + // Versioning requires backend processing that can take several seconds + await page.waitForTimeout(10000) + + // Click "Try it" button and wait for network request to complete + const responsePromise = page.waitForResponse(response => + response.url().includes('/flags/') && response.request().method() === 'GET' + ); + await helpers.click('#try-it-btn') + await responsePromise + + // Additional wait for UI to update with results + await page.waitForTimeout(1000) + json = await parseTryItResults(page) + expect(json.c.enabled).toBe(true) + + // Refresh page to verify state was persisted to backend + log('Refresh page to verify toggle ON persisted') + await page.reload() + await helpers.waitForElementVisible(byId('features-page')); + await waitForFeatureSwitch(page, 'c', 'on'); + + log('Versioned toggle test passed'); +}) \ No newline at end of file diff --git a/frontend/e2e/tests/versioning-tests.ts b/frontend/e2e/tests/versioning-tests.ts deleted file mode 100644 index 03ce7f8cf14f..000000000000 --- a/frontend/e2e/tests/versioning-tests.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - assertNumberOfVersions, - byId, - checkApiRequest, - click, - compareVersion, - createFeature, - createOrganisationAndProject, - createRemoteConfig, - editRemoteConfig, - getFlagsmith, - log, - login, - parseTryItResults, - toggleFeature, - waitForElementVisible, -} from '../helpers.cafe'; -import { t } from 'testcafe'; -import { E2E_USER, PASSWORD } from '../config'; - -// Request logger to verify versioned toggle uses the versions API endpoint -// Versioned: POST /environments/{envId}/features/{featureId}/versions/ -const versionApiLogger = checkApiRequest(/\/features\/\d+\/versions\/$/, 'post') - -export default async () => { - const flagsmith = await getFlagsmith() - const hasFeature = flagsmith.hasFeature("feature_versioning") - log('Login') - await login(E2E_USER, PASSWORD) - if(!hasFeature) { - log("Skipping version test, feature not enabled.") - return - } - - await createOrganisationAndProject('Flagsmith Versioning Org', 'Flagsmith Versioning Project') - await waitForElementVisible(byId('features-page')) - await click('#env-settings-link') - await click(byId('enable-versioning')) - await click('#confirm-btn-yes') - await waitForElementVisible(byId('feature-versioning-enabled')) - - log('Create feature 1') - await createRemoteConfig(0, 'a', 'small') - log('Edit feature 1') - await editRemoteConfig(0,'medium') - - log('Create feature 2') - await createRemoteConfig(1, 'b', 'small', null, null, [ - { value: 'medium', weight: 100 }, - { value: 'big', weight: 0 }, - ]) - log('Edit feature 2') - await editRemoteConfig(1,'small',false,[ - { value: 'medium', weight: 0 }, - { value: 'big', weight: 100 }, - ]) - - log('Create feature 3') - await createFeature(2, 'c', false) - log('Edit feature 3') - await editRemoteConfig(2,'',true) - - log('Assert version counts') - await assertNumberOfVersions(0, 2) - await assertNumberOfVersions(1, 2) - await assertNumberOfVersions(2, 2) - await compareVersion(0,0,null,true,true, 'small','medium') - await compareVersion(1,0,null,true,true, 'small','small') - await compareVersion(2,0,null,false,true, null,null) - - // =================================================================================== - // Test: Row toggle in versioned environment - // This tests that toggling a feature via the row switch works when Feature Versioning - // is enabled. The toggle must use the versioning API instead of the regular PUT. - // We reuse the existing versioned environment from the tests above. - // Note: Feature 'c' is currently ON after editRemoteConfig(2,'',true) above. - // =================================================================================== - log('Test row toggle in versioned environment') - - // Clear any previous requests from the logger - await t.addRequestHooks(versionApiLogger) - versionApiLogger.clear() - - // Feature 'c' (index 2) is currently ON - toggle it OFF - log('Toggle feature OFF via row switch (versioned env)') - await toggleFeature(2, false) - - // Verify: Versioned API endpoint was called (POST /features/{id}/versions/) - log('Verify versioned API endpoint was called') - await t.expect(versionApiLogger.requests.length).gte(1, 'Expected versioned API to be called') - - // Verify: Switch shows OFF state on features list - await waitForElementVisible(byId('feature-switch-2-off')) - - // Verify: API returns correct state (feature disabled) - log('Verify API returns disabled state') - await t.wait(500) - await click('#try-it-btn') - await t.wait(500) - let json = await parseTryItResults() - await t.expect(json.c.enabled).eql(false) - - // Refresh page to verify state was persisted to backend - log('Refresh page to verify toggle OFF persisted') - await t.eval(() => location.reload()) - await waitForElementVisible(byId('features-page')) - await waitForElementVisible(byId('feature-switch-2-off')) - - // Clear logger before second toggle - versionApiLogger.clear() - - // Toggle feature 'c' back ON using row switch - log('Toggle feature ON via row switch (versioned env)') - await toggleFeature(2, true) - - // Verify: Versioned API endpoint was called again - log('Verify versioned API endpoint was called for toggle ON') - await t.expect(versionApiLogger.requests.length).gte(1, 'Expected versioned API to be called for toggle ON') - - // Verify: Switch shows ON state on features list - await waitForElementVisible(byId('feature-switch-2-on')) - - // Verify: API returns correct state (feature enabled) - log('Verify API returns enabled state') - await t.wait(500) - await click('#try-it-btn') - await t.wait(500) - json = await parseTryItResults() - await t.expect(json.c.enabled).eql(true) - - // Refresh page to verify state was persisted to backend - log('Refresh page to verify toggle ON persisted') - await t.eval(() => location.reload()) - await waitForElementVisible(byId('features-page')) - await waitForElementVisible(byId('feature-switch-2-on')) - - log('Versioned toggle test passed') -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ae8249e330ed..a5d2db7ea714 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -111,7 +111,6 @@ "style-loader": "1.3.0", "suppress-exit-code": "^1.0.0", "terser-webpack-plugin": "^5.3.6", - "testcafe-react-selectors": "^5.0.3", "toml": "^3.0.0", "ts-node": "^10.9.1", "webpack": "5.94.0", @@ -126,7 +125,9 @@ "devDependencies": { "@dword-design/eslint-plugin-import-alias": "^2.0.7", "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@playwright/test": "^1.57.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", + "@types/archiver": "^6.0.2", "@types/classnames": "^2.3.1", "@types/color": "^3.0.3", "@types/dompurify": "^3.0.2", @@ -139,6 +140,7 @@ "@types/react-window-infinite-loader": "^1.0.9", "@typescript-eslint/eslint-plugin": "5.4.0", "@typescript-eslint/parser": "5.4.0", + "archiver": "^7.0.1", "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-import": "2.27.5", @@ -154,11 +156,11 @@ "lint-staged": "^12.3.4", "minimist": "^1.2.8", "nodemon": "^3.0.1", + "playwright": "^1.57.0", "prettier": "^2.5.1", "raw-loader": "0.5.1", "react-refresh": "^0.14.2", "ssgrtk": "^0.3.5", - "testcafe": "^3.7.3", "ts-jest": "^29.4.6", "typescript": "4.6.4" }, @@ -167,12 +169,6 @@ "npm": "10.x" } }, - "node_modules/@adobe/css-tools": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", - "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", - "license": "MIT" - }, "node_modules/@amplitude/analytics-browser": { "version": "2.22.0", "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.22.0.tgz", @@ -495,12 +491,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -509,9 +505,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -557,13 +553,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -598,12 +594,12 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -717,27 +713,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -856,25 +852,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -979,23 +975,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz", - "integrity": "sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-decorators": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", @@ -1063,48 +1042,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", - "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-flow": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", - "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", @@ -1139,6 +1076,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" @@ -1587,22 +1525,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", - "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-flow": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", @@ -2094,92 +2016,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.23.3.tgz", - "integrity": "sha512-XcQ3X58CKBdBnnZpPaQjgVMePsXtSZzHoku70q9tUAQp02ggPQNM04BF3RvlW1GSM/McbSOQAzEK4MXbS7/JFg==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "babel-plugin-polyfill-corejs2": "^0.4.6", - "babel-plugin-polyfill-corejs3": "^0.8.5", - "babel-plugin-polyfill-regenerator": "^0.5.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz", - "integrity": "sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==", - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz", - "integrity": "sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.4", - "core-js-compat": "^3.33.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz", - "integrity": "sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.5.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-regenerator/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", - "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", @@ -2431,23 +2267,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/preset-flow": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz", - "integrity": "sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-flow-strip-types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -2530,31 +2349,31 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", "debug": "^4.3.1" }, "engines": { @@ -2562,9 +2381,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2628,83 +2447,6 @@ "@datadog/framepost": "^0.3.0" } }, - "node_modules/@devexpress/bin-v8-flags-filter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@devexpress/bin-v8-flags-filter/-/bin-v8-flags-filter-1.3.0.tgz", - "integrity": "sha512-LWLNfYGwVJKYpmHUDoODltnlqxdEAl5Qmw7ha1+TSpsABeF94NKSWkQTTV1TB4CM02j2pZyqn36nHgaFl8z7qw==", - "license": "MIT" - }, - "node_modules/@devexpress/callsite-record": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@devexpress/callsite-record/-/callsite-record-4.1.7.tgz", - "integrity": "sha512-qr3VQYc0KopduFkEY6SxaOIi1Xhm0jIWQfrxxMVboI/p2rjF/Mj/iqaiUxQQP6F3ujpW/7l0mzhf17uwcFZhBA==", - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.14.72", - "callsite": "^1.0.0", - "chalk": "^2.4.0", - "error-stack-parser": "^2.1.4", - "highlight-es": "^1.0.0", - "lodash": "4.6.1 || ^4.16.1", - "pinkie-promise": "^2.0.0" - } - }, - "node_modules/@devexpress/callsite-record/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@devexpress/callsite-record/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@devexpress/callsite-record/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@devexpress/callsite-record/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@devexpress/callsite-record/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -2764,53 +2506,6 @@ "url": "https://github.com/sponsors/dword-design" } }, - "node_modules/@electron/asar": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", - "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", - "license": "MIT", - "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "bin": { - "asar": "bin/asar.js" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/@electron/asar/node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/@electron/asar/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -3632,22 +3327,6 @@ } } }, - "node_modules/@jest/core/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/diff-sequences": { "version": "30.0.1", "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", @@ -3996,21 +3675,21 @@ } }, "node_modules/@jest/transform/node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -4984,14 +4663,30 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", - "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "ansi-html": "^0.0.9", + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", + "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-html": "^0.0.9", "core-js-pure": "^3.23.3", "error-stack-parser": "^2.0.6", "html-entities": "^2.1.0", @@ -5570,9 +5265,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.46", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.46.tgz", - "integrity": "sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==", + "version": "0.34.47", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", + "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==", "dev": true, "license": "MIT" }, @@ -5586,16 +5281,6 @@ "type-detect": "4.0.8" } }, - "node_modules/@sinonjs/commons/node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@sinonjs/fake-timers": { "version": "13.0.5", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", @@ -5710,6 +5395,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/archiver": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz", + "integrity": "sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5897,16 +5592,6 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, - "node_modules/@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "license": "MIT", - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, "node_modules/@types/hast": { "version": "2.3.10", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", @@ -6010,12 +5695,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", - "license": "MIT" - }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", @@ -6025,12 +5704,6 @@ "@types/unist": "^2" } }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -6194,6 +5867,16 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -6895,6 +6578,19 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6920,21 +6616,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-hammerhead": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/acorn-hammerhead/-/acorn-hammerhead-0.6.2.tgz", - "integrity": "sha512-JZklfs1VVyjA1hf1y5qSzKSmK3K1UUUI7fQTuM/Zhv3rz4kFhdx4QwVnmU6tBEC8g/Ov6B+opfNFPeSZrlQfqA==", - "license": "MIT", - "dependencies": { - "@types/estree": "0.0.46" - } - }, - "node_modules/acorn-hammerhead/node_modules/@types/estree": { - "version": "0.0.46", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.46.tgz", - "integrity": "sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg==", - "license": "MIT" - }, "node_modules/acorn-import-attributes": { "version": "1.9.5", "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", @@ -6966,15 +6647,6 @@ "node": ">=0.4.0" } }, - "node_modules/address": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/address/-/address-2.0.3.tgz", - "integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==", - "license": "MIT", - "engines": { - "node": ">= 16.0.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -6991,6 +6663,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", @@ -7074,6 +6747,7 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -7089,6 +6763,7 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -7135,6 +6810,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -7150,6 +6826,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -7162,6 +6839,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/ansicolors": { @@ -7196,6 +6874,198 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -7226,12 +7096,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-find": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-find/-/array-find-1.0.0.tgz", - "integrity": "sha512-kO/vVCacW9mnpn3WPWbTVlEnOabK2L7LWi2HViURtCM46y1zb6I8UMjx4LgbiqadTgHnLInUronwn3ampNTJtQ==", - "license": "MIT" - }, "node_modules/array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", @@ -7279,15 +7143,6 @@ "node": ">=8" } }, - "node_modules/array-uniq": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", - "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array.prototype.flat": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", @@ -7354,15 +7209,6 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -7377,17 +7223,9 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, "license": "MIT" }, - "node_modules/async-exit-hook": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-1.1.2.tgz", - "integrity": "sha512-CeTSWB5Bou31xSHeO45ZKgLPRaJbV4I8csRcFYETDBehX7H+1GDO/v+v8G7fZmar1gOmYa6UTXn6d/WIiJbslw==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7643,6 +7481,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-1.0.4.tgz", "integrity": "sha512-g+8yxHUZ60RcyaUpfNzy56OtWW+x9cyEe9j+CranqLiqbju2yf/Cy6ZtYK40EZxtrdHllzlVZgLmcOUCTlJ7Jg==", + "dev": true, "license": "MIT" }, "node_modules/babel-plugin-emotion": { @@ -7882,12 +7721,6 @@ "integrity": "sha512-21/MnmUFduLr4JzxrKMm/MeF+Jjyi5UdZo38IqzrP0sLhmPbal5ZAUJ4HgWH4339SdjnYgENacbY5wfk/zxTGg==", "license": "MIT" }, - "node_modules/babel-plugin-syntax-trailing-function-commas": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", - "integrity": "sha512-Gx9CH3Q/3GKbhs07Bszw5fPTlU+ygrOGfAhEt7W2JICwufpC4SuO0mG0+4NykPBSYPMJhqvVlDBU17qB1D+hMQ==", - "license": "MIT" - }, "node_modules/babel-plugin-transform-object-rest-spread": { "version": "7.0.0-beta.3", "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-7.0.0-beta.3.tgz", @@ -7963,6 +7796,21 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -8090,12 +7938,6 @@ "@popperjs/core": "^2.11.6" } }, - "node_modules/bowser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -8198,8 +8040,18 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-from": { - "version": "1.1.2", + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" @@ -8261,14 +8113,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsite": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", - "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", - "engines": { - "node": "*" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -8332,23 +8176,6 @@ "cdl": "bin/cdl.js" } }, - "node_modules/chai": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", - "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^3.0.1", - "get-func-name": "^2.0.0", - "pathval": "^1.1.1", - "type-detect": "^4.0.5" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8413,18 +8240,6 @@ "dev": true, "license": "MIT" }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -8461,25 +8276,6 @@ "node": ">= 6" } }, - "node_modules/chrome-remote-interface": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.32.2.tgz", - "integrity": "sha512-3UbFKtEmqApehPQnqdblcggx7KveQphEMKQmdJZsOguE9ylw2N2/9Z7arO7xS55+DBJ/hyP8RrayLt4MMdJvQg==", - "license": "MIT", - "dependencies": { - "commander": "2.11.x", - "ws": "^7.2.0" - }, - "bin": { - "chrome-remote-interface": "bin/client.js" - } - }, - "node_modules/chrome-remote-interface/node_modules/commander": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", - "license": "MIT" - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -8490,10 +8286,20 @@ } }, "node_modules/ci-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", - "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==", - "license": "MIT" + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } }, "node_modules/cjs-module-lexer": { "version": "2.2.0", @@ -8533,6 +8339,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8736,19 +8543,6 @@ "node": ">= 0.12.0" } }, - "node_modules/coffeescript": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-2.7.0.tgz", - "integrity": "sha512-hzWp6TUE2d/jCcN67LrW1eh5b/rSDKQK6oD6VMLlggYVUUFexgTH9z3dNYihzX4RMhze5FTUsUmOXViJKFQR/A==", - "license": "MIT", - "bin": { - "cake": "bin/cake", - "coffee": "bin/coffee" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -8843,6 +8637,78 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "license": "MIT" }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -8962,6 +8828,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -9002,6 +8869,75 @@ "node": ">= 6" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/create-emotion": { "version": "9.2.12", "resolved": "https://registry.npmjs.org/create-emotion/-/create-emotion-9.2.12.tgz", @@ -9117,16 +9053,6 @@ "node": ">= 8" } }, - "node_modules/crypto-md5": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/crypto-md5/-/crypto-md5-1.0.0.tgz", - "integrity": "sha512-65Mtei8+EkSIK+5Ie4gpWXoJ/5bgpqPXFknHHXAyhDqKsEAAzUslGd8mOeawbfcuQ8fADNKcF4xQA3fqlZJ8Ig==", - "license": "BSD", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.5.2" - } - }, "node_modules/css-loader": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", @@ -9507,6 +9433,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/@dword-design/dedent/-/dedent-0.7.0.tgz", "integrity": "sha512-OFmAmzKiDUh9m7WRMYcoEOPI7b5tS5hdqQmtKDwF+ZssVJv8a+GHo9VOtFsmlw3h8Roh/9QzFWIsjSFZyQUMdg==", + "dev": true, "license": "MIT", "dependencies": { "babel-plugin-add-module-exports": "^1.0.2" @@ -9524,18 +9451,6 @@ "node": ">=0.10.0" } }, - "node_modules/deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -9602,99 +9517,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/del": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/del/-/del-3.0.0.tgz", - "integrity": "sha512-7yjqSoVSlJzA4t/VUwazuEagGeANEKB3f/aNI//06pfKgwoCb7f6Q1gETN1sZzYaj6chTQ0AhIwDiPdfOjko4A==", - "license": "MIT", - "dependencies": { - "globby": "^6.1.0", - "is-path-cwd": "^1.0.0", - "is-path-in-cwd": "^1.0.0", - "p-map": "^1.1.1", - "pify": "^3.0.0", - "rimraf": "^2.2.8" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/del/node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "license": "MIT", - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/del/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/del/node_modules/globby": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", - "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", - "license": "MIT", - "dependencies": { - "array-union": "^1.0.1", - "glob": "^7.0.3", - "object-assign": "^4.0.1", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/del/node_modules/globby/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/del/node_modules/p-map": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz", - "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/del/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/delay": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", @@ -9735,16 +9557,6 @@ "node": ">=6" } }, - "node_modules/des.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -9765,18 +9577,6 @@ "node": ">=8" } }, - "node_modules/device-specs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/device-specs/-/device-specs-1.0.1.tgz", - "integrity": "sha512-rxns/NDZfbdYumnn801z9uo8kWIz3Eld7Bk/F0V9zw4sZemSoD93+gxHEonLdxYulkws4iCMt7ZP8zuM8EzUSg==", - "license": "MIT" - }, - "node_modules/devtools-protocol": { - "version": "0.0.1109433", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1109433.tgz", - "integrity": "sha512-w1Eqih66egbSr2eOoGZ+NsdF7HdxmKDo3pKFBySEGsmVvwWWNXzNCDcKrbFnd23Jf7kH1M806OfelXwu+Jk11g==", - "license": "BSD-3-Clause" - }, "node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -10010,30 +9810,17 @@ "integrity": "sha512-G5COfnp3w+ydVu80yprgWSfmfQaYRh9DOxfhAxstLyetKaLyl55QrNjx8C38Pc/C+RaDmb1M0Lk8wPEMQ+bGgQ==", "license": "ISC" }, - "node_modules/elegant-spinner": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz", - "integrity": "sha512-B+ZM+RXvRqQaAmkMlO/oSe5nMUOaUnyfGYCEHoR8wrXsZR2mA0XVibsxV1bvTwxdRWah1PkQqso2EzhILGHtEQ==", + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/email-validator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz", - "integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==", - "engines": { - "node": ">4.0" - } - }, - "node_modules/emittery": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.4.1.tgz", - "integrity": "sha512-r4eRSeStEGf6M5SKdrQhhLK5bOwOBxQhIE3YSTnZE3GpKiLfnnhE+tPtrJE79+eDJgm39BM6LSoI8SCx4HbwlQ==", - "license": "MIT", - "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, "node_modules/emoji-regex": { @@ -10071,15 +9858,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -10127,6 +9905,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, "license": "MIT", "dependencies": { "stackframe": "^1.3.4" @@ -10797,21 +10576,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esotope-hammerhead": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/esotope-hammerhead/-/esotope-hammerhead-0.6.9.tgz", - "integrity": "sha512-rD9Jbh0SFJzKe1RGfsbwpN5IBdubHKC61xRW7A5BPgBTtEnFxsWOqPITVhBaVDc4r5VPmh+Y1U1wmqReTfn1AQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "0.0.46" - } - }, - "node_modules/esotope-hammerhead/node_modules/@types/estree": { - "version": "0.0.46", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.46.tgz", - "integrity": "sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg==", - "license": "MIT" - }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -10902,6 +10666,16 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", @@ -10917,6 +10691,16 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -11124,6 +10908,13 @@ "node": ">=6.0.0" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -11822,15 +11613,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -11855,18 +11637,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-os-info": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-os-info/-/get-os-info-1.0.2.tgz", - "integrity": "sha512-Nlgt85ph6OHZ4XvTcC8LMLDDFUzf7LAinYJZUwzrnc3WiO+vDEHDmNItTtzixBDLv94bZsvJGrrDRAE6uPs4MQ==", - "license": "ISC", - "dependencies": { - "getos": "^3.2.1", - "macos-release": "^3.0.1", - "os-family": "^1.1.0", - "windows-release": "^5.0.1" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -11890,15 +11660,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stdin": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", - "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -11929,15 +11690,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "license": "MIT", - "dependencies": { - "async": "^3.2.0" - } - }, "node_modules/glob": { "version": "9.3.5", "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", @@ -12076,15 +11828,6 @@ "dev": true, "license": "MIT" }, - "node_modules/graphlib": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", - "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.15" - } - }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -12279,79 +12022,6 @@ "he": "bin/he" } }, - "node_modules/highlight-es": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/highlight-es/-/highlight-es-1.0.3.tgz", - "integrity": "sha512-s/SIX6yp/5S1p8aC/NRDC1fwEb+myGIfp8/TzZz0rtAv8fzsdX7vGl3Q1TrXCsczFq8DI3CBFBCySPClfBSdbg==", - "license": "MIT", - "dependencies": { - "chalk": "^2.4.0", - "is-es2016-keyword": "^1.0.0", - "js-tokens": "^3.0.0" - } - }, - "node_modules/highlight-es/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/highlight-es/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/highlight-es/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/highlight-es/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/highlight-es/node_modules/js-tokens": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", - "integrity": "sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg==", - "license": "MIT" - }, - "node_modules/highlight-es/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/highlight.js": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.0.1.tgz", @@ -12583,12 +12253,6 @@ "entities": "^2.0.0" } }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause" - }, "node_modules/http-call": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/http-call/-/http-call-5.3.0.tgz", @@ -12694,45 +12358,6 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, - "node_modules/http-status-codes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", - "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", - "license": "MIT" - }, - "node_modules/httpntlm": { - "version": "1.8.13", - "resolved": "https://registry.npmjs.org/httpntlm/-/httpntlm-1.8.13.tgz", - "integrity": "sha512-2F2FDPiWT4rewPzNMg3uPhNkP3NExENlUGADRUDPQvuftuUTGW98nLZtGemCIW3G40VhWZYgkIDcQFAwZ3mf2Q==", - "funding": [ - { - "type": "paypal", - "url": "https://www.paypal.com/donate/?hosted_button_id=2CKNJLZJBW8ZC" - }, - { - "type": "buymeacoffee", - "url": "https://www.buymeacoffee.com/samdecrock" - } - ], - "dependencies": { - "des.js": "^1.0.1", - "httpreq": ">=0.4.22", - "js-md4": "^0.3.2", - "underscore": "~1.12.1" - }, - "engines": { - "node": ">=10.4.0" - } - }, - "node_modules/httpreq": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/httpreq/-/httpreq-1.1.1.tgz", - "integrity": "sha512-uhSZLPPD2VXXOSN8Cni3kIsoFHaU2pT/nySEU/fHr/ePbqHYr0jeiQRmUKLEirC09SFPsdMoA7LU7UXMd/w0Kw==", - "license": "MIT", - "engines": { - "node": ">= 6.15.1" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -12755,12 +12380,6 @@ "node": ">=10.17.0" } }, - "node_modules/humanize-duration": { - "version": "3.33.0", - "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.0.tgz", - "integrity": "sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==", - "license": "Unlicense" - }, "node_modules/husky": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", @@ -12904,15 +12523,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-lazy": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-3.1.0.tgz", - "integrity": "sha512-8/gvXvX2JMn0F+CDlSC4l6kOmVaLOO3XLkksI7CI3Ud95KDYJuYur2b9P/PUt/i/pDAMd/DulQsNbbbmRRsDIQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -13040,6 +12650,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13357,18 +12968,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz", - "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==", - "license": "MIT", - "dependencies": { - "ci-info": "^1.5.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -13433,6 +13032,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -13450,12 +13050,6 @@ "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", "license": "MIT" }, - "node_modules/is-es2016-keyword": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-es2016-keyword/-/is-es2016-keyword-1.0.0.tgz", - "integrity": "sha512-JtZWPUwjdbQ1LIo9OSZ8MdkWEve198ors27vH+RzUUvZXXZkzXCxFnlUhzWYxy5IexQSRiXVw9j2q/tHMmkVYQ==", - "license": "MIT" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -13481,18 +13075,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-finite": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", - "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-fullwidth-code-point": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", @@ -13634,43 +13216,11 @@ "node": ">=0.10.0" } }, - "node_modules/is-path-cwd": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", - "integrity": "sha512-cnS56eR9SPAscL77ik76ATVqoPARTqPIVkMDVxRaWH06zT+6+CzIroYRJ0VVvm0Z1zfAvxvz9i/D3Ppjaqt5Nw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-path-in-cwd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", - "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", - "license": "MIT", - "dependencies": { - "is-path-inside": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-path-in-cwd/node_modules/is-path-inside": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", - "integrity": "sha512-qhsCR/Esx4U4hg/9I19OVUAJkGWtjRYHMRgUMZE2TDdj+Ag+kttZanLupfddNyglzz50cUlmWzUaI37GDfNx/g==", - "license": "MIT", - "dependencies": { - "path-is-inside": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13700,18 +13250,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-podman": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-podman/-/is-podman-1.0.1.tgz", - "integrity": "sha512-+5vbtF5FIg262iUa7gOIseIWTx0740RHiax7oSmJMhbfSoBIMQ/IacKKgfnGj65JGeH9lGEVQcdkDwhn1Em1mQ==", - "license": "MIT", - "bin": { - "is-podman": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -13843,12 +13381,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-utf8": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", - "license": "MIT" - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -13958,21 +13490,21 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -14274,21 +13806,21 @@ } }, "node_modules/jest-config/node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -14314,22 +13846,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/jest-config/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/jest-config/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -14679,19 +14195,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner/node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, "node_modules/jest-runner/node_modules/jest-worker": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", @@ -14881,21 +14384,21 @@ } }, "node_modules/jest-snapshot/node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -14946,22 +14449,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-util/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/jest-validate": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", @@ -15013,19 +14500,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-watcher/node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -15073,12 +14547,6 @@ "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==", "license": "MIT" }, - "node_modules/js-md4": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", - "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15301,15 +14769,6 @@ "node": ">=0.10.0" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/klona": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", @@ -15319,6 +14778,59 @@ "node": ">= 8" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -15433,15 +14945,6 @@ "node": ">= 6" } }, - "node_modules/linux-platform-info": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/linux-platform-info/-/linux-platform-info-0.0.3.tgz", - "integrity": "sha512-FZhfFOIz0i4EGAvM4fQz+eayE9YzMuTx45tbygWYBttNapyiODg85BnAlQ1xnahEkvIM87T98XhXSfW8JAClHg==", - "license": "MIT", - "dependencies": { - "os-family": "^1.0.0" - } - }, "node_modules/listr2": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-4.0.5.tgz", @@ -15684,39 +15187,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update-async-hook": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/log-update-async-hook/-/log-update-async-hook-2.0.7.tgz", - "integrity": "sha512-V9KpD1AZUBd/oiZ+/Xsgd5rRP9awhgtRiDv5Am4VQCixiDnAbXMdt/yKz41kCzYZtVbwC6YCxnWEF3zjNEwktA==", - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.3.2", - "async-exit-hook": "^1.1.2", - "onetime": "^2.0.1", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/log-update-async-hook/node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/log-update-async-hook/node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/log-update/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -15812,18 +15282,6 @@ "yallist": "^3.0.2" } }, - "node_modules/macos-release": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-3.4.0.tgz", - "integrity": "sha512-wpGPwyg/xrSp4H4Db4xYSeAr6+cFQGHfspHzDUdYxswDnUW0L5Ov63UuJiSr8NMSpyaChO4u1n0MXUvVPtrN6A==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -15871,24 +15329,6 @@ "tmpl": "1.0.5" } }, - "node_modules/match-url-wildcard": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/match-url-wildcard/-/match-url-wildcard-0.0.4.tgz", - "integrity": "sha512-R1XhQaamUZPWLOPtp4ig5j+3jctN+skhgRmEQTUamMzmNtRG69QEirQs0NZKLtHMR7tzWpmtnS4Eqv65DcgXUA==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - } - }, - "node_modules/match-url-wildcard/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/material-ui-chip-input": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/material-ui-chip-input/-/material-ui-chip-input-1.1.0.tgz", @@ -16601,12 +16041,6 @@ "webpack": "^5.0.0" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -16664,12 +16098,6 @@ "node": "*" } }, - "node_modules/moment-duration-format-commonjs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/moment-duration-format-commonjs/-/moment-duration-format-commonjs-1.0.1.tgz", - "integrity": "sha512-KhKZRH21/+ihNRWrmdNFOyBptFi7nAWZFeFsRRpXkzgk/Yublb4fxyP0jU6EY1VDxUL/VUPdCmm/wAnpbfXdfw==", - "license": "MIT" - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -16694,18 +16122,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mustache": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-2.3.2.tgz", - "integrity": "sha512-KpMNwdQsYz3O/SBS1qJ/o3sqUJ5wSb8gb0pul8CO0S56b9Y2ALm8zCfsjPXsqGFfoNBkDwZuZIAjhsZI03gYVQ==", - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - }, - "engines": { - "npm": ">=1.4.0" - } - }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -17177,12 +16593,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/os-family": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/os-family/-/os-family-1.1.0.tgz", - "integrity": "sha512-E3Orl5pvDJXnVmpaAA2TeNNpNhTMl4o5HghuWhOivBjEiTnJSrMYSa5uZMek1lBEvu8kKEsa2YgVcGFVDqX/9w==", - "license": "MIT" - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -17378,11 +16788,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-1.5.1.tgz", - "integrity": "sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==" - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -17431,12 +16836,6 @@ "node": ">=0.10.0" } }, - "node_modules/path-is-inside": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", - "license": "(WTFPL OR MIT)" - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -17498,15 +16897,6 @@ "node": ">=8" } }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -17553,27 +16943,6 @@ "node": ">=6" } }, - "node_modules/pinkie": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinkie-promise": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", - "license": "MIT", - "dependencies": { - "pinkie": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -17711,6 +17080,53 @@ "node": ">=6" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -17721,15 +17137,6 @@ "node": ">=4" } }, - "node_modules/pngjs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", - "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/polyfill-react-native": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/polyfill-react-native/-/polyfill-react-native-1.0.5.tgz", @@ -17980,15 +17387,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-hrtime": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", - "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -17998,10 +17396,21 @@ "node": ">=6" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -18028,28 +17437,6 @@ "integrity": "sha512-FuEc12/eKqqoRYIGBrUptCBRhobL19PS2U31vMNTfyck1FxPyMfgsXyW4Mav85y/ZN1hop3hOwRlUDok23oYfQ==", "license": "MIT" }, - "node_modules/promisify-event": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/promisify-event/-/promisify-event-1.0.0.tgz", - "integrity": "sha512-mshw5LiFmdtphcuUGKyd3t6zmmgIVxrdZ8v4R1INAXHvMemUsDCqIUeq5QUIqqDfed8ZZ6uhov1PqhrdBvHOIA==", - "license": "MIT", - "dependencies": { - "pinkie-promise": "^2.0.0" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/prop-types": { "version": "15.6.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", @@ -18089,18 +17476,6 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -18108,16 +17483,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -18150,14 +17515,6 @@ "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", "license": "MIT" }, - "node_modules/qrcode-terminal": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.10.0.tgz", - "integrity": "sha512-ZvWjbAj4MWAj6bnCc9CnculsXnJr7eoKsvH/8rVpZbqYxP2z05HNQa43ZVwe/dVRcFxgfFHE2CkUqn0sCyLfHw==", - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, "node_modules/qrcode.react": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-1.0.0.tgz", @@ -18187,12 +17544,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -19046,15 +18397,6 @@ "react-dom": ">=16.8.0" } }, - "node_modules/read-file-relative": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/read-file-relative/-/read-file-relative-1.2.0.tgz", - "integrity": "sha512-lwZUlN2tQyPa62/XmVtX1MeNLVutlRWwqvclWU8YpOCgjKdhg2zyNkeFjy7Rnjo3txhKCy5FGgAi+vx59gvkYg==", - "license": "MIT", - "dependencies": { - "callsite": "^1.0.0" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -19070,22 +18412,55 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" + "minimatch": "^5.1.0" } }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -19421,27 +18796,6 @@ "integrity": "sha512-wXe1vJucZjrhQL0SxOL9EvmJrtbMCIEGMdZX5lj/57n2T3UhBHZsAcM5TQASJ0T6ZBbrETRnMhH33bsbJeRO6Q==", "license": "MIT" }, - "node_modules/repeating": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/repeating/-/repeating-1.1.3.tgz", - "integrity": "sha512-Nh30JLeMHdoI+AsQ5eblhZ7YlTsM9wiJQe/AHIunlK3KWzvXhXb36IJ7K1IOeRjIOtzMjdUHjwXUFxKJoPTSOg==", - "license": "MIT", - "dependencies": { - "is-finite": "^1.0.0" - }, - "bin": { - "repeating": "cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/replicator": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/replicator/-/replicator-1.0.5.tgz", - "integrity": "sha512-saxS4y7NFkLMa92BR4bPHR41GD+f/qoDAwD2xZmN+MpDXgibkxwLO2qk7dCHYtskSkd/bWS8Jy6kC5MZUkg1tw==", - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -19509,27 +18863,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-cwd": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-1.0.0.tgz", - "integrity": "sha512-ac27EnKWWlc2yQ/5GCoCGecqVJ9MSmgiwvUYOS+9A+M0dn1FdP5mnsDZ9gwx+lAvh/d7f4RFn4jLfggRRYxPxw==", - "license": "MIT", - "dependencies": { - "resolve-from": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", - "integrity": "sha512-qpFcKaXsq8+oRoLilkwyc7zHGF5i9Q2/25NIgLQQ/+VVv9rU4qvr6nXVAw1DsnXJyQkZsR4Ytfbtg5ehfcUssQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -19754,15 +19087,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", - "license": "WTFPL OR ISC", - "dependencies": { - "truncate-utf8-bytes": "^1.0.0" - } - }, "node_modules/sass": { "version": "1.54.8", "resolved": "https://registry.npmjs.org/sass/-/sass-1.54.8.tgz", @@ -19967,12 +19291,6 @@ "node": ">= 0.8.0" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "license": "MIT" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -20238,12 +19556,6 @@ "node": ">= 10" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -20384,6 +19696,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true, "license": "MIT" }, "node_modules/statuses": { @@ -20409,6 +19722,18 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -20773,9 +20098,9 @@ } }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -20803,6 +20128,33 @@ "node": ">=6" } }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar-stream/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/terser": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz", @@ -20833,1123 +20185,128 @@ "terser": "^5.31.1" }, "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/terser-webpack-plugin/node_modules/terser": { - "version": "5.43.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", - "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/terser/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/testcafe": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/testcafe/-/testcafe-3.7.3.tgz", - "integrity": "sha512-PZfNGVXYX+KjKgHepsnPv4xgeA+PK9GiQF+OUl4R2tG8KBjqFgGP1sl5UOnFIIaL6ncbf5Erhpubt0VvsJlJ/w==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.23.2", - "@babel/plugin-proposal-decorators": "^7.23.2", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.22.5", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-class-static-block": "^7.24.7", - "@babel/plugin-transform-exponentiation-operator": "^7.22.5", - "@babel/plugin-transform-for-of": "^7.22.15", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.25.4", - "@babel/plugin-transform-runtime": "7.23.3", - "@babel/preset-env": "^7.23.2", - "@babel/preset-flow": "^7.22.15", - "@babel/preset-react": "^7.22.15", - "@babel/runtime": "^7.23.2", - "@devexpress/bin-v8-flags-filter": "^1.3.0", - "@devexpress/callsite-record": "^4.1.6", - "@types/node": "20.14.5", - "address": "^2.0.2", - "async-exit-hook": "^1.1.2", - "babel-plugin-module-resolver": "5.0.0", - "babel-plugin-syntax-trailing-function-commas": "^6.22.0", - "bowser": "^2.8.1", - "callsite": "^1.0.0", - "chai": "4.3.4", - "chalk": "^2.3.0", - "chrome-remote-interface": "^0.32.2", - "coffeescript": "^2.3.1", - "commander": "^8.3.0", - "debug": "^4.3.1", - "dedent": "^0.4.0", - "del": "^3.0.0", - "device-specs": "^1.0.0", - "devtools-protocol": "0.0.1109433", - "diff": "^4.0.2", - "elegant-spinner": "^1.0.1", - "email-validator": "^2.0.4", - "emittery": "^0.4.1", - "error-stack-parser": "^2.1.4", - "execa": "^4.0.3", - "get-os-info": "^1.0.2", - "globby": "^11.0.4", - "graceful-fs": "^4.1.11", - "graphlib": "^2.1.5", - "http-status-codes": "^2.2.0", - "humanize-duration": "^3.25.0", - "import-lazy": "^3.1.0", - "indent-string": "^1.2.2", - "is-ci": "^1.0.10", - "is-docker": "^2.0.0", - "is-glob": "^2.0.1", - "is-podman": "^1.0.1", - "is-stream": "^2.0.0", - "json5": "^2.2.2", - "lodash": "^4.17.21", - "log-update-async-hook": "^2.0.7", - "make-dir": "^3.0.0", - "mime-db": "^1.41.0", - "moment": "^2.29.4", - "moment-duration-format-commonjs": "^1.0.0", - "mustache": "^2.1.2", - "nanoid": "^3.1.31", - "os-family": "^1.0.0", - "parse5": "^1.5.0", - "pify": "^2.3.0", - "pinkie": "^2.0.4", - "pngjs": "^3.3.1", - "pretty-hrtime": "^1.0.3", - "promisify-event": "^1.0.0", - "prompts": "^2.4.2", - "qrcode-terminal": "^0.10.0", - "read-file-relative": "^1.2.0", - "replicator": "^1.0.5", - "resolve-cwd": "^1.0.0", - "resolve-from": "^4.0.0", - "sanitize-filename": "^1.6.0", - "semver": "^7.5.3", - "set-cookie-parser": "^2.5.1", - "source-map-support": "^0.5.16", - "strip-bom": "^2.0.0", - "testcafe-browser-tools": "2.0.26", - "testcafe-hammerhead": "31.7.6", - "testcafe-legacy-api": "5.1.8", - "testcafe-reporter-json": "^2.1.0", - "testcafe-reporter-list": "^2.2.0", - "testcafe-reporter-minimal": "^2.2.0", - "testcafe-reporter-spec": "^2.2.0", - "testcafe-reporter-xunit": "^2.2.1", - "testcafe-selector-generator": "^0.1.0", - "time-limit-promise": "^1.0.2", - "tmp": "0.2.5", - "tree-kill": "^1.2.2", - "typescript": "4.7.4", - "unquote": "^1.1.1", - "url-to-options": "^2.0.0" - }, - "bin": { - "testcafe": "bin/testcafe-with-v8-flag-filter.js" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/testcafe-browser-tools": { - "version": "2.0.26", - "resolved": "https://registry.npmjs.org/testcafe-browser-tools/-/testcafe-browser-tools-2.0.26.tgz", - "integrity": "sha512-nTKSJhBzn9BmnOs0xVzXMu8dN2Gu13Ca3x3SJr/zF6ZdKjXO82JlbHu55dt5MFoWjzAQmwlqBkSxPaYicsTgUw==", - "license": "MIT", - "dependencies": { - "array-find": "^1.0.0", - "debug": "^4.3.1", - "dedent": "^0.7.0", - "del": "^5.1.0", - "execa": "^3.3.0", - "fs-extra": "^10.0.0", - "graceful-fs": "^4.1.11", - "linux-platform-info": "^0.0.3", - "lodash": "^4.17.15", - "mkdirp": "^0.5.1", - "mustache": "^2.1.2", - "nanoid": "^3.1.31", - "os-family": "^1.0.0", - "pify": "^2.3.0", - "pinkie": "^2.0.1", - "read-file-relative": "^1.2.0", - "which-promise": "^1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/testcafe-browser-tools/node_modules/del": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/del/-/del-5.1.0.tgz", - "integrity": "sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==", - "license": "MIT", - "dependencies": { - "globby": "^10.0.1", - "graceful-fs": "^4.2.2", - "is-glob": "^4.0.1", - "is-path-cwd": "^2.2.0", - "is-path-inside": "^3.0.1", - "p-map": "^3.0.0", - "rimraf": "^3.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/testcafe-browser-tools/node_modules/execa": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-3.4.0.tgz", - "integrity": "sha512-r9vdGQk4bmCuK1yKQu1KTwcT2zwfWdbdaXfCtAh+5nU/4fSX+JAb7vZGvI5naJrQlvONrEB20jeruESI69530g==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "p-finally": "^2.0.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": "^8.12.0 || >=9.7.0" - } - }, - "node_modules/testcafe-browser-tools/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/testcafe-browser-tools/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/testcafe-browser-tools/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/testcafe-browser-tools/node_modules/globby": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", - "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", - "license": "MIT", - "dependencies": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/testcafe-browser-tools/node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/testcafe-browser-tools/node_modules/is-path-cwd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", - "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/testcafe-browser-tools/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/testcafe-browser-tools/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/testcafe-browser-tools/node_modules/p-finally": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz", - "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/testcafe-browser-tools/node_modules/p-map": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/testcafe-browser-tools/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/testcafe-browser-tools/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/testcafe-browser-tools/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/testcafe-hammerhead": { - "version": "31.7.6", - "resolved": "https://registry.npmjs.org/testcafe-hammerhead/-/testcafe-hammerhead-31.7.6.tgz", - "integrity": "sha512-PkYW+je+xiOi6hzEl7Rv6w4Aqawxr1wTMt6je/wYT3MkU6b4s2WKwF9MIg5thA3/TUt3djV+BJPzbWo3JInV3w==", - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.3.0-rc.1", - "@electron/asar": "^3.2.3", - "acorn-hammerhead": "0.6.2", - "bowser": "1.6.0", - "crypto-md5": "^1.0.0", - "debug": "4.3.1", - "esotope-hammerhead": "0.6.9", - "http-cache-semantics": "^4.1.0", - "httpntlm": "^1.8.10", - "iconv-lite": "0.5.1", - "lodash": "^4.17.21", - "lru-cache": "11.0.2", - "match-url-wildcard": "0.0.4", - "merge-stream": "^1.0.1", - "mime": "~1.4.1", - "mustache": "^2.1.1", - "nanoid": "^3.1.12", - "os-family": "^1.0.0", - "parse5": "^7.1.2", - "pinkie": "2.0.4", - "read-file-relative": "^1.2.0", - "semver": "7.5.3", - "tough-cookie": "4.1.3", - "tunnel-agent": "0.6.0", - "ws": "^7.4.6" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/testcafe-hammerhead/node_modules/bowser": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-1.6.0.tgz", - "integrity": "sha512-Fk23J0+vRnI2eKDEDoUZXWtbMjijr098lKhuj4DKAfMKMCRVfJOuxXlbpxy0sTgbZ/Nr2N8MexmOir+GGI/ZMA==", - "license": "MIT" - }, - "node_modules/testcafe-hammerhead/node_modules/debug": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", - "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/testcafe-hammerhead/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/testcafe-hammerhead/node_modules/iconv-lite": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.1.tgz", - "integrity": "sha512-ONHr16SQvKZNSqjQT9gy5z24Jw+uqfO02/ngBSBoqChZ+W8qXX7GPRa1RoUnzGADw8K63R1BXUMzarCVQBpY8Q==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/testcafe-hammerhead/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/testcafe-hammerhead/node_modules/lru-cache": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", - "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/testcafe-hammerhead/node_modules/merge-stream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-1.0.1.tgz", - "integrity": "sha512-e6RM36aegd4f+r8BZCcYXlO2P3H6xbUM6ktL2Xmf45GAOit9bI4z6/3VU7JwllVO1L7u0UDSg/EhzQ5lmMLolA==", - "license": "MIT", - "dependencies": { - "readable-stream": "^2.0.1" - } - }, - "node_modules/testcafe-hammerhead/node_modules/mime": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", - "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==", - "license": "MIT", - "bin": { - "mime": "cli.js" - } - }, - "node_modules/testcafe-hammerhead/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "license": "MIT" - }, - "node_modules/testcafe-hammerhead/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/testcafe-hammerhead/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/testcafe-hammerhead/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/testcafe-hammerhead/node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/testcafe-hammerhead/node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/testcafe-hammerhead/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/testcafe-hammerhead/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/testcafe-legacy-api": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/testcafe-legacy-api/-/testcafe-legacy-api-5.1.8.tgz", - "integrity": "sha512-Jp/8xPQ+tjr2iS569Og8fFRaSx/7h/N/t6DVzhWpVNO3D5AtPkGmSjCAABh7tHkUwrKfBI7sLuVaxekiT5PWTA==", - "license": "MIT", - "dependencies": { - "async": "3.2.3", - "dedent": "^0.6.0", - "highlight-es": "^1.0.0", - "lodash": "^4.14.0", - "moment": "^2.14.1", - "mustache": "^2.2.1", - "os-family": "^1.0.0", - "parse5": "^2.1.5", - "pify": "^2.3.0", - "pinkie": "^2.0.1", - "read-file-relative": "^1.2.0", - "strip-bom": "^2.0.0", - "testcafe-hammerhead": ">=19.4.0" - } - }, - "node_modules/testcafe-legacy-api/node_modules/async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", - "license": "MIT" - }, - "node_modules/testcafe-legacy-api/node_modules/dedent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.6.0.tgz", - "integrity": "sha512-cSfRWjXJtZQeRuZGVvDrJroCR5V2UvBNUMHsPCdNYzuAG8b9V8aAy3KUcdQrGQPXs17Y+ojbPh1aOCplg9YR9g==", - "license": "MIT" - }, - "node_modules/testcafe-legacy-api/node_modules/parse5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-2.2.3.tgz", - "integrity": "sha512-yJQdbcT+hCt6HD+BuuUvjHUdNwerQIKSJSm7tXjtp6oIH5Mxbzlt/VIIeWxblsgcDt1+E7kxPeilD5McWswStA==", - "license": "MIT" - }, - "node_modules/testcafe-legacy-api/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/testcafe-legacy-api/node_modules/strip-bom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", - "license": "MIT", - "dependencies": { - "is-utf8": "^0.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/testcafe-react-selectors": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/testcafe-react-selectors/-/testcafe-react-selectors-5.0.3.tgz", - "integrity": "sha512-UBqkuQwrPmoxc//KUEtDiAkcns99EhgDNwVa3Q662yY8cQL01hbInXKqY1smOZRHEQwjnFjA2cEL7BHrQzR/pg==", - "license": "MIT", - "peerDependencies": { - "testcafe": ">1.0.0" - } - }, - "node_modules/testcafe-reporter-json": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/testcafe-reporter-json/-/testcafe-reporter-json-2.2.0.tgz", - "integrity": "sha512-wfpNaZgGP2WoqdmnIXOyxcpwSzdH1HvzXSN397lJkXOrQrwhuGUThPDvyzPnZqxZSzXdDUvIPJm55tCMWbfymQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/testcafe-reporter-list": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/testcafe-reporter-list/-/testcafe-reporter-list-2.2.0.tgz", - "integrity": "sha512-+6Q2CC+2B90OYED2Yx6GoBIMUYd5tADNUbOHu3Hgdd3qskzjBdKwpdDt0b7w0w7oYDO1/Uu4HDBTDud3lWpD4Q==", - "license": "MIT" - }, - "node_modules/testcafe-reporter-minimal": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/testcafe-reporter-minimal/-/testcafe-reporter-minimal-2.2.0.tgz", - "integrity": "sha512-iUSWI+Z+kVUAsGegMmEXKDiMPZHDxq+smo4utWwc3wI3Tk6jT8PbNvsROQAjwkMKDmnpo6To5vtyvzvK+zKGXA==", - "license": "MIT" - }, - "node_modules/testcafe-reporter-spec": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/testcafe-reporter-spec/-/testcafe-reporter-spec-2.2.0.tgz", - "integrity": "sha512-4jUN75Y7eaHQfSjiCLBXt/TvJMW76kBaZGC74sq03FJNBLoo8ibkEFzfjDJzNDCRYo+P7FjCx3vxGrzgfQU26w==", - "license": "MIT" - }, - "node_modules/testcafe-reporter-xunit": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/testcafe-reporter-xunit/-/testcafe-reporter-xunit-2.2.3.tgz", - "integrity": "sha512-aGyc+MZPsTNwd9SeKJSjFNwEZfILzFnObzOImaDbsf57disTQfEY+9japXWav/Ef5Cv04UEW24bTFl2Q4f8xwg==", - "license": "MIT" - }, - "node_modules/testcafe-selector-generator": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/testcafe-selector-generator/-/testcafe-selector-generator-0.1.0.tgz", - "integrity": "sha512-MTw+RigHsEYmFgzUFNErDxui1nTYUk6nm2bmfacQiKPdhJ9AHW/wue4J/l44mhN8x3E8NgOUkHHOI+1TDFXiLQ==", - "license": "MIT" - }, - "node_modules/testcafe/node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/testcafe/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/testcafe/node_modules/@types/node": { - "version": "20.14.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.5.tgz", - "integrity": "sha512-aoRR+fJkZT2l0aGOJhuA8frnCSoNX6W7U2mpNq63+BxBIj5BQFt8rHy627kijCmm63ijdSdwvGgpUsU6MBsZZA==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/testcafe/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/testcafe/node_modules/babel-plugin-module-resolver": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-5.0.0.tgz", - "integrity": "sha512-g0u+/ChLSJ5+PzYwLwP8Rp8Rcfowz58TJNCe+L/ui4rpzE/mg//JVX0EWBUYoxaextqnwuGHzfGp2hh0PPV25Q==", - "license": "MIT", - "dependencies": { - "find-babel-config": "^2.0.0", - "glob": "^8.0.3", - "pkg-up": "^3.1.0", - "reselect": "^4.1.7", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/testcafe/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/testcafe/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/testcafe/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/testcafe/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/testcafe/node_modules/dedent": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.4.0.tgz", - "integrity": "sha512-25DJIXD6mCqYHIqI3/aBfAvFgJSY9jIx397eUQSofXbWVR4lcB21a17qQ5Bswj0Zv+3Nf06zNCyfkGyvo0AqqQ==", - "license": "MIT" - }, - "node_modules/testcafe/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/testcafe/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/testcafe/node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/testcafe/node_modules/find-babel-config": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/find-babel-config/-/find-babel-config-2.1.2.tgz", - "integrity": "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==", - "license": "MIT", - "dependencies": { - "json5": "^2.2.3" - } - }, - "node_modules/testcafe/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/testcafe/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/testcafe/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/testcafe/node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/testcafe/node_modules/indent-string": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-1.2.2.tgz", - "integrity": "sha512-Z1vqf6lDC3f4N2mWqRywY6odjRatPNGDZgUr4DY9MLC14+Fp2/y+CI/RnNGlb8hD6ckscE/8DlZUwHUaiDBshg==", - "license": "MIT", - "dependencies": { - "get-stdin": "^4.0.1", - "minimist": "^1.1.0", - "repeating": "^1.1.0" - }, - "bin": { - "indent-string": "cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/testcafe/node_modules/is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/testcafe/node_modules/is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/testcafe/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/testcafe/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" + "node": ">= 10.13.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } } }, - "node_modules/testcafe/node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } + "node_modules/terser-webpack-plugin/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" }, - "node_modules/testcafe/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "license": "ISC", + "node_modules/terser-webpack-plugin/node_modules/terser": { + "version": "5.43.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", + "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", + "license": "BSD-2-Clause", "dependencies": { - "brace-expansion": "^2.0.1" + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" }, "engines": { "node": ">=10" } }, - "node_modules/testcafe/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/terser/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/testcafe/node_modules/strip-bom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", - "license": "MIT", + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", "dependencies": { - "is-utf8": "^0.2.0" + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/testcafe/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", "dependencies": { - "has-flag": "^3.0.0" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">=4" + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/testcafe/node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "license": "MIT", - "engines": { - "node": ">=14.14" + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" } }, - "node_modules/testcafe/node_modules/typescript": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", - "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "peerDependencies": { + "react-native-b4a": "*" }, - "engines": { - "node": ">=4.2.0" + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } } }, - "node_modules/testcafe/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -21964,15 +20321,6 @@ "dev": true, "license": "MIT" }, - "node_modules/time-limit-promise": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/time-limit-promise/-/time-limit-promise-1.0.4.tgz", - "integrity": "sha512-FLHDDsIDducw7MBcRWlFtW2Tm50DoKOSFf0Nzx17qwXj8REXCte0eUkHrJl9QU3Bl9arG3XNYX0PcHpZ9xyuLw==", - "license": "MIT", - "engines": { - "node": ">= 0.12" - } - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -22045,36 +20393,12 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tough-cookie": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", - "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -22095,15 +20419,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", - "license": "WTFPL", - "dependencies": { - "utf8-byte-length": "^1.0.1" - } - }, "node_modules/ts-jest": { "version": "29.4.6", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", @@ -22294,6 +20609,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -22316,9 +20632,10 @@ } }, "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -22506,12 +20823,6 @@ "dev": true, "license": "MIT" }, - "node_modules/underscore": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", - "license": "MIT" - }, "node_modules/undici-types": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", @@ -22667,15 +20978,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -22697,12 +20999,6 @@ "webpack-virtual-modules": "^0.5.0" } }, - "node_modules/unquote": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", - "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", - "license": "MIT" - }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -22777,25 +21073,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "node_modules/url-to-options": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-2.0.0.tgz", - "integrity": "sha512-mfONnc9dqO0J41wUh/El+plDskrIJRcyLcx6WjEGYW2K11RnjPDAgeoNFCallADaYJfcWIvAlYyZPBw02AbfIQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/use-sync-external-store": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", @@ -22805,12 +21082,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/utf8-byte-length": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", - "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", - "license": "(WTFPL OR MIT)" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -23346,59 +21617,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-promise": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-promise/-/which-promise-1.0.0.tgz", - "integrity": "sha512-15ahjtDr3H+RBtTrvBcKhOFhIEiN3RZSCevDPWtBys+QUivZX9cYyNJcyWNIrUMVsgGrEuIThif9jxeEAQFauw==", - "license": "MIT", - "dependencies": { - "pify": "^2.2.0", - "pinkie-promise": "^1.0.0", - "which": "^1.1.2" - } - }, - "node_modules/which-promise/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/which-promise/node_modules/pinkie": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-1.0.0.tgz", - "integrity": "sha512-VFVaU1ysKakao68ktZm76PIdOhvEfoNNRaGkyLln9Os7r0/MCxqHjHyBM7dT3pgTiBybqiPtpqKfpENwdBp50Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/which-promise/node_modules/pinkie-promise": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-1.0.0.tgz", - "integrity": "sha512-5mvtVNse2Ml9zpFKkWBpGsTPwm3DKhs+c95prO/F6E7d6DN0FPqxs6LONpLNpyD7Iheb7QN4BbUoKJgo+DnkQA==", - "license": "MIT", - "dependencies": { - "pinkie": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/which-promise/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -23472,21 +21690,6 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "license": "MIT" }, - "node_modules/windows-release": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-5.1.1.tgz", - "integrity": "sha512-NMD00arvqcq2nwqc5Q6KtrSRHK+fVD31erE5FEMahAw5PmVCgD7MUXodq3pdZSUkqA9Cda2iWx6s1XYwiJWRmw==", - "license": "MIT", - "dependencies": { - "execa": "^5.1.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -23507,6 +21710,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -23575,12 +21779,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -23590,6 +21796,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -23784,6 +21991,63 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 2cf5a15dcd9e..5dc39259ca9c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,12 +9,15 @@ "postinstall": "npm run env", "build": "npm run bundle", "husky:install": "cd ../ && husky install", - "test:bundle:staging": "cross-env E2E=1 ENV=staging npm run bundle", - "test:dev": "cross-env NODE_ENV=production E2E=true E2E_DEV=true ts-node -T ./e2e/index.cafe", - "test:devlocal": "cross-env NODE_ENV=production E2E_LOCAL=true E2E_CONCURRENCY=1 E2E=true E2E_DEV=true ts-node -T ./e2e/index.cafe", + "test": "npx tsx e2e/run-with-retry.ts", + "test:run": "npm run test:bundle && cross-env NODE_ENV=production E2E=true npx playwright test", + "test:stress": "bash e2e/run-e2e-stress-test.sh", + "test:stress:build": "docker compose -f docker-compose-e2e-tests.yml build", + "test:dev": "cross-env NODE_ENV=production E2E=true E2E_DEV=true npx playwright test --ui", + "test:devlocal": "cross-env NODE_ENV=production E2E_LOCAL=true E2E_CONCURRENCY=1 E2E=true E2E_DEV=true npx playwright test --ui", "test:devBundle": "npm run test:bundle && npm run test:dev", - "test": "npm run test:bundle && cross-env NODE_ENV=production E2E=true ts-node -T ./e2e/index.cafe", - "test:staging": "npm run test:bundle:staging && cross-env NODE_ENV=production E2E=true ENV=staging ts-node -T ./e2e/index.cafe", + "test:install": "npx playwright install firefox", + "test:teardown": "npx tsx e2e/teardown.ts", "test:unit": "jest", "test:unit:watch": "jest --watch", "test:unit:coverage": "jest --coverage", @@ -139,7 +142,6 @@ "style-loader": "1.3.0", "suppress-exit-code": "^1.0.0", "terser-webpack-plugin": "^5.3.6", - "testcafe-react-selectors": "^5.0.3", "toml": "^3.0.0", "ts-node": "^10.9.1", "webpack": "5.94.0", @@ -154,7 +156,9 @@ "devDependencies": { "@dword-design/eslint-plugin-import-alias": "^2.0.7", "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@playwright/test": "^1.57.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", + "@types/archiver": "^6.0.2", "@types/classnames": "^2.3.1", "@types/color": "^3.0.3", "@types/dompurify": "^3.0.2", @@ -167,6 +171,7 @@ "@types/react-window-infinite-loader": "^1.0.9", "@typescript-eslint/eslint-plugin": "5.4.0", "@typescript-eslint/parser": "5.4.0", + "archiver": "^7.0.1", "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-import": "2.27.5", @@ -182,11 +187,11 @@ "lint-staged": "^12.3.4", "minimist": "^1.2.8", "nodemon": "^3.0.1", + "playwright": "^1.57.0", "prettier": "^2.5.1", "raw-loader": "0.5.1", "react-refresh": "^0.14.2", "ssgrtk": "^0.3.5", - "testcafe": "^3.7.3", "ts-jest": "^29.4.6", "typescript": "4.6.4" }, diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000000..4d1ac569e6ee --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,102 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +require('dotenv').config() + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Global setup and teardown */ + globalSetup: require.resolve('./e2e/global-setup.playwright.ts'), + + globalTeardown: require.resolve('./e2e/global-teardown.playwright.ts'), + /* Stop after first failure when E2E_RETRIES=0 (fail fast mode) */ + maxFailures: process.env.E2E_RETRIES === '0' ? 1 : undefined, + /* Output directory for test results */ + outputDir: './e2e/test-results', + /* Configure projects for major browsers */ + projects: [ + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + headless: !process.env.E2E_DEV, + + // Launch options for Firefox + launchOptions: { + // Try to use system Firefox if PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD was set + executablePath: process.env.PLAYWRIGHT_FIREFOX_PATH || undefined, + firefoxUserPrefs: { + // Disable auto-updates to prevent version conflicts + 'app.update.auto': false, + 'app.update.enabled': false, + // Disable cache + 'browser.cache.disk.enable': false, + 'browser.cache.memory.enable': false, + 'browser.cache.offline.enable': false, + 'network.http.use-cache': false, + }, + }, + + // Clear storage before each test to prevent contamination + storageState: undefined, + }, + }, + ], + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + [ + 'html', + { + open: 'never', + outputFolder: './e2e/playwright-report', + title: 'Flagsmith E2E Test Results', + }, + ], + ['json', { outputFile: './e2e/test-results/results.json' }], + ['list', { printSteps: false }], // Only shows test names with pass/fail status + ], + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + testDir: './e2e', + testMatch: /.*\.pw\.ts$/, + /* Test timeout */ + timeout: 120000, + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Action timeout */ + actionTimeout: 20000, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${process.env.PORT || 8080}`, + /* Navigation timeout */ + navigationTimeout: 20000, + /* Screenshot on all tests for maximum detail */ + screenshot: 'on', + /* Collect trace on all tests for maximum detail */ + trace: 'on', + /* Video on all tests for maximum detail */ + video: 'on', + }, + /* Run your local dev server before starting the tests */ + webServer: process.env.E2E_LOCAL + ? undefined + : { + command: 'npm run start', + port: 8080, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, + /* Opt out of parallel tests on CI. */ + workers: process.env.E2E_CONCURRENCY + ? parseInt(process.env.E2E_CONCURRENCY) + : 3, +})