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..b8b0bcdd1331 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,79 @@ 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: 2 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: Send Slack notification and upload report + if: failure() + working-directory: frontend + run: | + cd e2e + zip -r playwright-report.zip playwright-report/ || echo "Failed to zip report" + cd .. + npm install --no-save @slack/web-api + npx -y tsx e2e/slack-e2e-reporter.ts + env: + SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} + GITHUB_ACTION_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + TEST_TYPE: ${{ steps.test-type.outputs.label }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + + - 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 }} - ${{ inputs.runs-on }})' + report-tag: 'playwright-${{ steps.test-type.outputs.label }}-${{ inputs.runs-on }}' + custom-info: | + **🔄 Run:** [#${{ github.run_number }} (attempt ${{ github.run_attempt }})](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + - 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 }} - ${{ inputs.runs-on }})' + report-tag: 'playwright-${{ steps.test-type.outputs.label }}-${{ inputs.runs-on }}' + custom-info: | + **📦 Artifacts:** [View test results and HTML report](${{ steps.artifact-url.outputs.url }}) + **🔄 Run:** [#${{ github.run_number }} (attempt ${{ github.run_attempt }})](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) 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/.claude/commands/e2e-create.md b/frontend/.claude/commands/e2e-create.md new file mode 100644 index 000000000000..2bbf350ca1ab --- /dev/null +++ b/frontend/.claude/commands/e2e-create.md @@ -0,0 +1,51 @@ +# E2E Test Creator + +Create a new E2E test following the existing patterns in the codebase. + +## Arguments + +- `$ARGUMENTS` = "" → Ask the user what to test +- `$ARGUMENTS` = "segment creation flow" → Create a test for that feature + +## CRITICAL: Read Context First + +**You MUST read `.claude/context/e2e.md` before proceeding.** It contains essential configuration, test structure, and debugging guides. + +## Workflow + +1. **Determine what to test:** + - If `$ARGUMENTS` is provided, use that as the test description + - Otherwise, ask the user what feature/page/flow to test + +2. **Determine if OSS or Enterprise:** + - Check if the feature exists in enterprise-only code paths + - Tag the test with `@oss` or `@enterprise` accordingly + +3. **Review existing patterns:** + - Read 2-3 similar test files from `frontend/e2e/tests/` + - Read `frontend/e2e/helpers.playwright.ts` for available utilities + +4. **Scan application code:** + - Find components being tested in `frontend/web/components/` or `frontend/common/` + - Add `data-test` attributes if missing + +5. **Create the test file:** + - Follow naming convention: `*-test.pw.ts` or `*-tests.pw.ts` + - Use helper functions from `helpers.playwright.ts` + +6. **Verify the test works:** + ```bash + SKIP_BUNDLE=1 E2E_CONCURRENCY=1 npm run test -- tests/new-test.pw.ts --quiet + ``` + Run twice to check for flakiness. On failure, follow the analysis workflow in context/e2e.md. + +7. **Report what was created:** + - Test file path + - Any `data-test` attributes added + - Test stability results + +## Important Notes + +- Always use `data-test` attributes for selectors +- Use existing helper functions instead of raw Playwright APIs +- Tests should be independent and not rely on execution order diff --git a/frontend/.claude/commands/e2e-ee.md b/frontend/.claude/commands/e2e-ee.md new file mode 100644 index 000000000000..df25a3deaff8 --- /dev/null +++ b/frontend/.claude/commands/e2e-ee.md @@ -0,0 +1,31 @@ +# E2E Enterprise Test Runner + +Run enterprise E2E tests (tagged with @enterprise) and report results. + +## Arguments + +- `$ARGUMENTS` = "" → Run tests once (default) +- `$ARGUMENTS` = "5" → Run tests 5 times, stopping on first failure + +## CRITICAL: Read Context First + +**You MUST read `.claude/context/e2e.md` before proceeding.** It contains essential configuration, failure analysis workflows, and fix patterns. + +## Run Command + +```bash +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep @enterprise --quiet +``` + +## Re-running Failed Enterprise Tests + +When re-running specific failed tests, include the grep flag: +```bash +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=1 npm run test -- tests/specific-test.pw.ts --grep @enterprise +``` + +## Workflow + +1. Run tests (one iteration at a time if multiple requested) +2. On failure: follow the failure analysis workflow in context/e2e.md +3. Report results diff --git a/frontend/.claude/commands/e2e-oss.md b/frontend/.claude/commands/e2e-oss.md new file mode 100644 index 000000000000..837d99b3c6df --- /dev/null +++ b/frontend/.claude/commands/e2e-oss.md @@ -0,0 +1,24 @@ +# E2E OSS Test Runner + +Run OSS (non-enterprise) E2E tests and report results. + +## Arguments + +- `$ARGUMENTS` = "" → Run tests once (default) +- `$ARGUMENTS` = "5" → Run tests 5 times, stopping on first failure + +## CRITICAL: Read Context First + +**You MUST read `.claude/context/e2e.md` before proceeding.** It contains essential configuration, failure analysis workflows, and fix patterns. + +## Run Command + +```bash +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep @oss --quiet +``` + +## Workflow + +1. Run tests (one iteration at a time if multiple requested) +2. On failure: follow the failure analysis workflow in context/e2e.md +3. Report results diff --git a/frontend/.claude/commands/e2e.md b/frontend/.claude/commands/e2e.md new file mode 100644 index 000000000000..6f9e6ccb5cec --- /dev/null +++ b/frontend/.claude/commands/e2e.md @@ -0,0 +1,24 @@ +# E2E Test Runner (All Tests) + +Run all E2E tests (both OSS and enterprise) and report results. + +## Arguments + +- `$ARGUMENTS` = "" → Run tests once (default) +- `$ARGUMENTS` = "5" → Run tests 5 times, stopping on first failure + +## CRITICAL: Read Context First + +**You MUST read `.claude/context/e2e.md` before proceeding.** It contains essential configuration, failure analysis workflows, and fix patterns. + +## Run Command + +```bash +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep "@oss|@enterprise" --quiet +``` + +## Workflow + +1. Run tests (one iteration at a time if multiple requested) +2. On failure: follow the failure analysis workflow in context/e2e.md +3. Report results with pass/fail counts for OSS and enterprise tests diff --git a/frontend/.claude/context/e2e.md b/frontend/.claude/context/e2e.md new file mode 100644 index 000000000000..aef1eb02df44 --- /dev/null +++ b/frontend/.claude/context/e2e.md @@ -0,0 +1,187 @@ +# E2E Testing Configuration and Context + +**CRITICAL: This file MUST be read before running any E2E commands. It contains essential configuration, debugging guides, and workflow instructions that all E2E commands depend on.** + +## 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: Tagged with `@oss` (use `--grep @oss`) + - Enterprise tests: Tagged with `@enterprise` (use `--grep @enterprise`) + - All tests: `--grep "@oss|@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/` +- View with: `npm run test:report` + +## 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 @oss` - Run only OSS tests +- `--grep "@oss|@enterprise"` - Run all tests +- `-x` - Stop after first failure (automatically added when `E2E_RETRIES=0`) + +## CRITICAL: Multiple Iterations + +**NEVER use `E2E_REPEAT` environment variable.** It runs tests automatically without control and clears reports on each iteration, destroying error context needed for debugging. + +When running multiple iterations: +1. Run tests **one iteration at a time** using separate bash commands +2. **STOP IMMEDIATELY** on any failure - do not continue to next iteration +3. Analyze error-context.md and report the failure +4. **Ask user for consent** before running additional iterations +5. Track iteration count: "Iteration X of Y passed" + +## CRITICAL: Failure Analysis Workflow + +**On ANY test failure, you MUST:** +1. Read error-context.md for the failed test +2. **STOP and report the failure summary to the user** +3. **Ask for user consent before doing ANYTHING else** (no investigating, no fixing, no additional iterations) + +Only proceed with investigation/fixes after the user explicitly approves. + +### Reading Order for Failed Tests + +1. **`error-context.md`** - Start here for DOM snapshot showing exact page state +2. **`failed.json`** - Error summary +3. **`trace.zip`** - Detailed trace if needed + +### Analyzing Traces + +```bash +cd frontend/e2e/test-results/ +unzip -q trace.zip +grep -i "error\|failed" 0-trace.network # Check for network errors +``` + +### Common Fix Patterns + +- **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 + +### Re-running Failed Tests + +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/specific-test.pw.ts +``` + +Maximum 3 fix/re-run cycles per test before reporting as unfixable. + +## CRITICAL: Flaky Test Policy + +Tests that fail initially but pass on retry are FLAKY and MUST be investigated, even if the final result shows all tests passed. + +- **DO NOT** just report that retries passed +- **DO** investigate and fix the root cause +- The built-in retry mechanism is a safety net, not a substitute for fixing flaky tests + +## Important Rules + +- **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 + +## CRITICAL: Use Helpers, Not Raw Page Methods + +**NEVER use `page.waitForTimeout()` or raw `page.locator()` methods.** Always use the helper functions instead: + +### Wait Helpers (Use These Instead of waitForTimeout) +- `waitForElementVisible(selector)` - Wait for element to be visible +- `waitForElementClickable(selector)` - Wait for element to be clickable +- `waitForToast()` - Wait for toast notification +- `waitAndRefresh()` - Wait and refresh page state +- `waitForFeatureSwitch(name, state)` - Wait for feature switch state +- `waitForUserFeatureSwitch(name, state)` - Wait for user feature switch state + +### Click Helpers (Use These Instead of page.locator().click()) +- `click(selector)` - Click element (handles wait, scroll, enabled check) +- `clickByText(text, element)` - Click element by text content +- `clickUserFeature(name)` - Click user feature +- `clickUserFeatureSwitch(name, state)` - Click user feature switch + +### Other Helpers +- `setText(selector, value)` - Set input text +- `closeModal()` - Close modal (instead of Escape key) +- `assertInputValue(selector, value)` - Assert input value +- `gotoFeatures()`, `gotoFeature(name)`, etc. - Navigation helpers + +**Why?** Helpers include proper waiting, error handling, and scrolling. Raw page methods lead to flaky tests. + +## Test Infrastructure + +### Playwright Configuration +- Browser: Firefox +- Test runner: `frontend/e2e/run-with-retry.ts` +- Helper functions: `frontend/e2e/helpers.playwright.ts` +- Global setup: `frontend/e2e/global-setup.playwright.ts` +- Global teardown: `frontend/e2e/global-teardown.playwright.ts` + +### 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 + +## 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 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..b46c91b20c00 --- /dev/null +++ b/frontend/E2E-LOCAL-TESTING.md @@ -0,0 +1,109 @@ +# Running E2E Tests Locally + +## Quick Start + +From the `frontend/` directory: + +```bash +# Run all tests in Docker (like CI) +make test + +# Run OSS tests only +make test-oss + +# Run enterprise tests only +make test-enterprise + +# Run with custom concurrency +E2E_CONCURRENCY=1 make test-oss +``` + +## 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 +# OSS tests only +make test-oss + +# Enterprise tests only +make test-enterprise + +# 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-oss +``` + +### 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..8a7fcfc4e536 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -28,6 +28,24 @@ serve: .PHONY: test test: - docker compose run frontend \ - npx cross-env E2E_CONCURRENCY=${E2E_CONCURRENCY} npm run test -- $(opts) \ - || (docker compose logs flagsmith-api; exit 1) + @echo "Running E2E tests..." + @docker compose run --name e2e-test-run frontend \ + sh -c 'npx cross-env E2E_CONCURRENCY=${E2E_CONCURRENCY} npm run test -- $(opts)' \ + || 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 + +.PHONY: test-oss +test-oss: + @$(MAKE) test opts="--grep @oss" + +.PHONY: test-enterprise +test-enterprise: + @$(MAKE) test opts="--grep @enterprise" diff --git a/frontend/README.md b/frontend/README.md index 01c794b60296..900f995126d8 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -77,3 +77,81 @@ API types are centralised in: - `common/types/responses.ts` - Response types For AI-assisted development, see [CLAUDE.md](https://github.com/Flagsmith/flagsmith/blob/main/frontend/CLAUDE.md). + +### E2E Testing + +E2E tests use Playwright with Firefox and include videos, traces, and HTML reports for debugging. + +#### Prerequisites + +1. Docker running with Flagsmith services: `docker compose up -d` +2. Environment variables in `docker-compose.yml`: + ```yaml + E2E_TEST_AUTH_TOKEN: 'some-token' + ENABLE_FE_E2E: 'true' + ``` +3. Matching token in `frontend/.env`: + ```bash + E2E_TEST_TOKEN_DEV=some-token + ``` + +#### Running Tests + +```bash +# Run all tests (builds bundle automatically) +npm run test + +# Run with Playwright UI (for debugging - build bundle first) +npm run bundle +npm run test:dev + +# Run specific test file +npm run test -- tests/flag-tests.pw.ts + +# Run only OSS tests +npm run test -- --grep @oss + +# Run only Enterprise tests +npm run test -- --grep @enterprise +``` + +#### Environment Variables + +| Variable | Description | +|----------|-------------| +| `SKIP_BUNDLE=1` | Skip webpack build for faster iteration | +| `E2E_CONCURRENCY=N` | Number of parallel workers (default: 20, use 1 for debugging) | +| `E2E_RETRIES=0` | Fail-fast mode - stop on first failure | +| `E2E_REPEAT=N` | Run tests N additional times after passing to detect flakiness | + +#### Examples + +```bash +# Fast iteration (skip bundle, fail on first error) +E2E_RETRIES=0 SKIP_BUNDLE=1 npm run test -- tests/flag-tests.pw.ts + +# Check for flaky tests (run 5 extra times after passing) +E2E_REPEAT=5 npm run test -- tests/flag-tests.pw.ts + +# Debug with low concurrency +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=1 npm run test -- tests/flag-tests.pw.ts +``` + +#### Test Results + +- **HTML Report**: `e2e/playwright-report/` - Interactive dashboard with search/filter +- **Test Artifacts**: `e2e/test-results/` - Contains for each failed test: + - `error-context.md` - DOM snapshot at failure point + - `trace.zip` - Interactive trace viewer + - Screenshots and videos + +#### Claude Code Commands + +When using Claude Code, these commands are available for e2e testing: + +- `/e2e [N]` - Run all tests (OSS + Enterprise), auto-fix failures, re-run until passing +- `/e2e-oss [N]` - Run OSS tests only, auto-fix failures, re-run until passing +- `/e2e-ee [N]` - Run Enterprise tests only, auto-fix failures, re-run until passing +- `/e2e-create [description]` - Create a new test following existing patterns + +The optional `[N]` argument sets `E2E_REPEAT` to run tests N additional times after passing (defaults to 0). E.g., `/e2e 5` runs tests, then repeats 5 more times to detect flakiness. diff --git a/frontend/api/index.js b/frontend/api/index.js index 6c8a3a3b7b55..3a1d2a546765 100755 --- a/frontend/api/index.js +++ b/frontend/api/index.js @@ -115,13 +115,14 @@ app.get('/config/project-overrides', (req, res) => { }, { name: 'e2eToken', - value: process.env.E2E_TEST_TOKEN || '', + value: process.env[`E2E_TEST_TOKEN_${(process.env.ENV || 'dev').toUpperCase()}`] || process.env.E2E_TEST_TOKEN || '', }, ] let output = values.map(getVariable).join('') res.setHeader('Cache-Control', 's-max-age=1, stale-while-revalidate') res.setHeader('content-type', 'application/javascript') - res.send(`window.projectOverrides = { + const e2eScript = process.env.E2E ? 'window.E2E=true;' : '' + res.send(`${e2eScript}window.projectOverrides = { ${output} };`) }) diff --git a/frontend/bin/upload-file.js b/frontend/bin/upload-file.js deleted file mode 100644 index 95f62d490bd8..000000000000 --- a/frontend/bin/upload-file.js +++ /dev/null @@ -1,31 +0,0 @@ -require('dotenv').config() -const { WebClient } = require('@slack/web-api') -const fs = require('fs') - -const SLACK_TOKEN = process.env.SLACK_TOKEN - -module.exports = function uploadFile(path) { - if (!SLACK_TOKEN) { - // eslint-disable-next-line - console.log('Slack token not specified, skipping upload'); - return - } - - const title = 'Test Run' // Optional - const epoch = new Date().valueOf() - const filename = `e2e-record-${epoch}.mp4` - const channelId = 'C0102JZRG3G' // infra_tests channel ID - // eslint-disable-next-line - console.log(`Uploading ${path}`); - - 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), - filename, - initial_comment: `✖ ${title} ${process.env.GITHUB_ACTION_URL || ''}`, - }) -} -new Date().valueOf() diff --git a/frontend/common/data/base/_data.js b/frontend/common/data/base/_data.js index d2ec3cb63172..629e34e9bdf3 100644 --- a/frontend/common/data/base/_data.js +++ b/frontend/common/data/base/_data.js @@ -48,14 +48,6 @@ module.exports = { options.body = '{}' } - if (E2E && document.getElementById('e2e-request')) { - const payload = { - options, - url, - } - document.getElementById('e2e-request').innerText = JSON.stringify(payload) - } - return fetch(url, options) .then((response) => this.status(response, isExternal)) .then((response) => { @@ -102,19 +94,6 @@ module.exports = { if (!isExternal && response.status === 401) { AppActions.setUser(null) } - response - .clone() - .text() // cloned so response body can be used downstream - .then((err) => { - if (E2E && document.getElementById('e2e-error')) { - const error = { - error: err, - status: response.status, - url: response.url, - } - document.getElementById('e2e-error').innerText = JSON.stringify(error) - } - }) return Promise.reject(response) }, diff --git a/frontend/common/stores/default-flags.ts b/frontend/common/stores/default-flags.ts index f3ea87e14fe0..de011ae06e58 100644 --- a/frontend/common/stores/default-flags.ts +++ b/frontend/common/stores/default-flags.ts @@ -197,7 +197,8 @@ const defaultFlags = { }, 'sentry': { 'description': 'Send flag change events to Sentry.', - 'docs': 'https://docs.flagsmith.com/third-party-integrations/observability-and-monitoring/sentry', + 'docs': + 'https://docs.flagsmith.com/third-party-integrations/observability-and-monitoring/sentry', 'fields': [ { 'key': 'webhook_url', diff --git a/frontend/common/useViewMode.ts b/frontend/common/useViewMode.ts index 6b92511de443..3681f37eef7f 100644 --- a/frontend/common/useViewMode.ts +++ b/frontend/common/useViewMode.ts @@ -1,10 +1,20 @@ import flagsmith from 'flagsmith' import { useState, useCallback } from 'react' -export type ViewMode = 'compact' | 'default' | 'release-manager' | 'executive' | 'dev' +export type ViewMode = + | 'compact' + | 'default' + | 'release-manager' + | 'executive' + | 'dev' export function getViewMode() { const viewMode = flagsmith.getTrait('view_mode') - if (viewMode === 'compact' || viewMode === 'release-manager' || viewMode === 'executive' || viewMode === 'dev') { + if ( + viewMode === 'compact' || + viewMode === 'release-manager' || + viewMode === 'executive' || + viewMode === 'dev' + ) { return viewMode as ViewMode } return 'default' diff --git a/frontend/docker-compose-e2e-tests.yml b/frontend/docker-compose-e2e-tests.yml index 4dd26cc6ff63..914efd143759 100644 --- a/frontend/docker-compose-e2e-tests.yml +++ b/frontend/docker-compose-e2e-tests.yml @@ -46,7 +46,7 @@ services: environment: E2E_TEST_TOKEN_DEV: some-token DISABLE_ANALYTICS_FEATURES: 'true' - FLAGSMITH_API: flagsmith-api:8000/api/v1/ + FLAGSMITH_API_URL: http://flagsmith-api:8000/api/v1/ SLACK_TOKEN: ${SLACK_TOKEN} GITHUB_ACTION_URL: ${GITHUB_ACTION_URL} ports: 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/config.ts b/frontend/e2e/config.ts index d626a5d7f65d..d09b12a1b8e8 100644 --- a/frontend/e2e/config.ts +++ b/frontend/e2e/config.ts @@ -11,3 +11,13 @@ export const E2E_NON_ADMIN_USER_WITH_A_ROLE = `e2e_non_admin_user_with_a_role@${ export const E2E_CHANGE_MAIL = `e2e_change_email@${E2E_EMAIL_DOMAIN}` export const E2E_SEPARATE_TEST_USER = `e2e_separate_test_user@${E2E_EMAIL_DOMAIN}` export const PASSWORD = 'Str0ngp4ssw0rd!' +export const E2E_TEST_IDENTITY = 'test-identity' + +// Project names (seeded by backend) +export const E2E_TEST_PROJECT = 'My Test Project' +export const E2E_SEGMENT_PROJECT_1 = 'My Test Project 2' +export const E2E_SEGMENT_PROJECT_2 = 'My Test Project 3' +export const E2E_SEGMENT_PROJECT_3 = 'My Test Project 4' +export const E2E_PROJECT_WITH_PROJECT_PERMISSIONS = 'My Test Project 5 Project Permission' +export const E2E_PROJECT_WITH_ENV_PERMISSIONS = 'My Test Project 6 Env Permission' +export const E2E_PROJECT_WITH_ROLE = 'My Test Project 7 Role' diff --git a/frontend/e2e/failed-tests-reporter.ts b/frontend/e2e/failed-tests-reporter.ts new file mode 100644 index 000000000000..096923eb799c --- /dev/null +++ b/frontend/e2e/failed-tests-reporter.ts @@ -0,0 +1,74 @@ +import { + Reporter, + FullConfig, + Suite, + TestCase, + TestResult, + FullResult, +} from '@playwright/test/reporter'; +import * as fs from 'fs'; +import * as path from 'path'; + +class FailedTestsReporter implements Reporter { + private failedTests: Array<{ + file: string; + title: string; + tests: Array<{ + status: string; + error: any; + duration: number; + retry: number; + }>; + }> = []; + + onBegin(config: FullConfig, suite: Suite) { + console.log('Starting E2E tests'); + } + + onTestEnd(test: TestCase, result: TestResult) { + if (result.status === 'failed' || result.status === 'timedOut') { + const specFile = path.relative( + path.join(__dirname, '..'), + test.location.file, + ); + + let failedSpec = this.failedTests.find((f) => f.file === specFile); + if (!failedSpec) { + failedSpec = { + file: specFile, + title: test.title, + tests: [], + }; + this.failedTests.push(failedSpec); + } + + failedSpec.tests.push({ + status: result.status, + error: result.error, + duration: result.duration, + retry: result.retry, + }); + } + } + + async onEnd(result: FullResult) { + const failedCount = this.failedTests.length; + + if (failedCount > 0) { + const failedPath = path.join(__dirname, 'test-results', 'failed.json'); + fs.writeFileSync( + failedPath, + JSON.stringify( + { failedTests: this.failedTests, timestamp: new Date().toISOString() }, + null, + 2, + ), + ); + console.log(`Found ${failedCount} failed test(s)`); + } + + console.log('E2E tests completed'); + } +} + +export default FailedTestsReporter; diff --git a/frontend/e2e/global-setup.playwright.ts b/frontend/e2e/global-setup.playwright.ts new file mode 100644 index 000000000000..681fe4e4de37 --- /dev/null +++ b/frontend/e2e/global-setup.playwright.ts @@ -0,0 +1,45 @@ +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'; +import { runTeardown } from './teardown'; + +async function globalSetup(config: FullConfig) { + console.log('Starting global setup for E2E tests...'); + + const testResultsDir = path.join(__dirname, 'test-results'); + + // 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/`; + + 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 + const success = await runTeardown(); + if (!success) { + throw new Error('E2E teardown failed'); + } + + console.log('Starting E2E tests'); +} + +export default globalSetup; diff --git a/frontend/e2e/helpers.cafe.ts b/frontend/e2e/helpers.cafe.ts deleted file mode 100644 index edc30cb02323..000000000000 --- a/frontend/e2e/helpers.cafe.ts +++ /dev/null @@ -1,687 +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 goToAccountSettings = async () => { - await click('#account-settings-link') - if (await isElementExists('account-settings-view-mode')) { - await click('#account-settings') - } -} - -export const logout = async () => { - await goToAccountSettings() - 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/browser-logging.playwright.ts b/frontend/e2e/helpers/browser-logging.playwright.ts new file mode 100644 index 000000000000..5bc76370a3dd --- /dev/null +++ b/frontend/e2e/helpers/browser-logging.playwright.ts @@ -0,0 +1,119 @@ +import { Page } from '@playwright/test'; + +// Map of status codes to URL patterns that should be ignored in logs +const IGNORE_RESPONSE_ERRORS: Record = { + 404: [ + '/usage-data/', // Expected for new orgs without billing + '/list-change-requests/', // Enterprise feature, expected in OSS + '/change-requests/', // Enterprise feature, expected in OSS + '/release-pipelines/', // Enterprise feature, expected in OSS + '/roles/', // May not exist in certain configurations + '/saml/configuration/', // Enterprise feature, expected in OSS + ], + 403: [ + '/get-subscription-metadata/', // Expected for non-admin users + '/usage-data/', // Expected for non-admin users + '/invite-links/', // Expected for non-admin users + '/roles/', // Expected for non-admin users + '/change-requests/', // Expected for non-admin users + '/api-keys/', // Expected for non-admin users + '/metrics/', // Expected for non-admin users + '/invites/', // Expected for non-admin users + ], + 429: [ + '/usage-data/', // Usage data endpoint has rate limiting, throttling is expected + ], +}; + +// 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 + const ignoreUrls = IGNORE_RESPONSE_ERRORS[status]; + if (ignoreUrls && ignoreUrls.some(pattern => url.includes(pattern))) { + 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)'); +}; diff --git a/frontend/e2e/helpers/e2e-helpers.playwright.ts b/frontend/e2e/helpers/e2e-helpers.playwright.ts new file mode 100644 index 000000000000..d0b3f4cd4a7d --- /dev/null +++ b/frontend/e2e/helpers/e2e-helpers.playwright.ts @@ -0,0 +1,1125 @@ +import { Page, expect } from '@playwright/test'; +import { LONG_TIMEOUT, byId, log, logUsingLastSection, getFlagsmith } from './utils.playwright'; + +// Re-export for backwards compatibility +export { LONG_TIMEOUT, byId, log, logUsingLastSection, getFlagsmith }; + + +export type MultiVariate = { value: string; weight: number }; + +export type Rule = { + name: string; + operator: string; + value: string | number | boolean; + ors?: Rule[]; +}; + +// 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 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 waitForModalToClose() { + await this.waitForElementNotExist('.modal-backdrop'); + } + + async waitForPageFullyLoaded() { + await this.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => { + // Silently continue if timeout - DOM might already be loaded + }); + } + + async waitForToastsToClear() { + await expect(this.page.locator('.toast-message')).toHaveCount(0, { timeout: LONG_TIMEOUT }); + } + + async waitForToast() { + await this.waitForElementVisible('.toast-message', LONG_TIMEOUT); + await this.waitForToastsToClear(); + } + + 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 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 expect(element).toBeAttached({ timeout: LONG_TIMEOUT }); + await element.scrollIntoViewIfNeeded(); + await expect(element).toBeEnabled({ timeout: LONG_TIMEOUT }); + 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 }).first(); + 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 gotoProject(projectName: string) { + logUsingLastSection(`Navigate to project: ${projectName}`); + // Use exact text matching (quoted string) to avoid substring matches like "Test Project" matching "My Test Project 2" + const projectLink = this.page.locator('a').filter({ has: this.page.locator(`text="${projectName}"`) }).first(); + await projectLink.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await projectLink.click(); + // Wait for project page to load - could be features page or create environment page + await this.page.waitForURL(/\/project\/\d+/, { timeout: LONG_TIMEOUT }); + await this.waitForPageFullyLoaded(); + } + + async login( + email: string = process.env.E2E_USER || '', + password: string = process.env.E2E_PASS || '', + ) { + if (!this.page.url().includes('/login')) { + try { + await this.page.goto('/login', { waitUntil: 'domcontentloaded' }); + } catch { + await this.page.goto('/login', { waitUntil: 'domcontentloaded' }); + } + } + // Wait for both fields to be visible + await this.waitForElementVisible('[name="email"]'); + await this.waitForElementVisible('[name="password"]'); + 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 gotoAccountSettings() { + const flagsmith = await getFlagsmith() + const hasPersonas = flagsmith.hasFeature('persona_based_views') + await this.click('#account-settings-link'); + if (hasPersonas) { + await this.click('#account-settings'); + } + } + + async logout() { + try { + await this.gotoAccountSettings(); + await this.click('#logout-link'); + await this.waitForElementVisible('#login-page'); + } catch (e) { + console.log('Could not log out:', e); + } + } + + async assertTextContent(selector: string, expectedText: string) { + await expect(this.page.locator(selector)).toContainText(expectedText); + } + + // 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); + }); + }); + } + + // Navigate to traits page for a specific user + async gotoTraits(identifier: string) { + await this.click('#features-link'); + await this.click('#users-link'); + const userRow = this.page.locator('[data-test^="user-item-"]').filter({ hasText: identifier }).first(); + await userRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await userRow.locator('a').first().click(); + await this.waitForElementVisible('#add-trait'); + } + + // Navigate to a user + async goToUser(identifier: string) { + await this.click('#features-link'); + await this.click('#users-link'); + const userRow = this.page.locator('[data-test^="user-item-"]').filter({ hasText: identifier }).first(); + await userRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await userRow.locator('a').first().click(); + } + + // Navigate to a feature + async gotoFeature(name: string) { + const featureRow = this.page.locator('[data-test^="feature-item-"]').filter({ + has: this.page.locator(`span:text-is("${name}")`) + }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await featureRow.dispatchEvent('click'); + await this.waitForElementVisible('#create-feature-modal'); + } + + // Create a feature + async createFeature({ name, value, description = 'description' }: { name: string, value?: string | boolean | number, description?: string }) { + await this.gotoFeatures(); + await this.click('#show-create-feature-btn'); + await this.setText(byId('featureID'), name); + await this.setText(byId('featureDesc'), description); + if (value) { + await this.click(byId('toggle-feature-button')); + } + await this.click(byId('create-feature-btn')); + const featureElement = this.page.locator('[data-test^="feature-item-"]', { hasText: name }).first(); + await featureElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await this.closeModal(); + } + + // Create a remote config + async createRemoteConfig({ name, value, description = 'description', defaultOff, mvs = [] }: { name: string, value: string | number | boolean, description?: string, defaultOff?: boolean, mvs?: MultiVariate[] }) { + const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}`; + await this.gotoFeatures(); + await this.click('#show-create-feature-btn'); + await this.setText(byId('featureID'), name); + await this.setText(byId('featureValue'), `${value}`); + await this.setText(byId('featureDesc'), description); + if (!defaultOff) { + await this.click(byId('toggle-feature-button')); + } + for (let i = 0; i < mvs.length; i++) { + const v = mvs[i]; + await this.click(byId('add-variation')); + await this.page.waitForTimeout(200); + await this.setText(byId(`featureVariationValue${i}`), v.value); + await this.setText(byId(`featureVariationWeight${v.value}`), `${v.weight}`); + await this.page.waitForTimeout(100); + } + await this.click(byId('create-feature-btn')); + const timeout = mvs.length > 0 ? 45000 : 20000; + const featureElement = this.page.locator('[data-test^="feature-item-"]').filter({ + has: this.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 this.closeModal(); + } + + // Delete a feature + async deleteFeature(name: string) { + const index = await this.getFeatureIndexByName(name); + await this.clickFeatureAction(name); + await this.click(byId(`feature-remove-${index}`)); + await this.setText('[name="confirm-feature-name"]', name); + await this.click('#confirm-remove-feature-btn'); + await this.waitForElementNotExist('.modal-open'); + await expect(this.page.locator('[data-test^="feature-item-"]').filter({ + has: this.page.locator(`span:text-is("${name}")`) + })).toHaveCount(0, { timeout: LONG_TIMEOUT }); + } + + // Toggle a feature + async toggleFeature(name: string, value: boolean) { + const featureRow = this.page.locator('[data-test^="feature-item-"]').filter({ + has: this.page.locator(`span:text-is("${name}")`) + }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + const currentState = value ? 'off' : 'on'; + const switchElement = featureRow.locator(`[data-test^="feature-switch-"][data-test$="-${currentState}"]`).first(); + await switchElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await switchElement.click(); + await this.click('#confirm-toggle-feature-btn'); + await this.waitForModalToClose(); + const newState = value ? 'on' : 'off'; + const newSwitchElement = featureRow.locator(`[data-test^="feature-switch-"][data-test$="-${newState}"]`).first(); + await newSwitchElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + } + + // Create a trait + async createTrait(name: string, value: string | boolean | number) { + await this.click('#add-trait'); + await this.waitForElementVisible('#create-trait-modal'); + await this.setText('[name="traitID"]', name); + await this.setText('[name="traitValue"]', `${value}`); + await this.click('#create-trait-btn'); + await this.page.waitForTimeout(2000); + await this.page.reload(); + const traitNameElement = this.page.locator('[class*="js-trait-key-"]').filter({ hasText: new RegExp(`^${name}$`) }); + await traitNameElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + const className = await traitNameElement.getAttribute('class'); + const index = className?.match(/js-trait-key-(\d+)/)?.[1]; + const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}`; + await expect(this.page.locator(byId(`user-trait-value-${index}`))).toContainText(expectedValue); + } + + // Delete a trait + async deleteTrait(name: string) { + const traitElement = this.page.locator('[class*="js-trait-key-"]').filter({ hasText: new RegExp(`^${name}$`) }); + await traitElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + const className = await traitElement.getAttribute('class'); + const index = className?.match(/js-trait-key-(\d+)/)?.[1]; + await this.click(byId(`delete-user-trait-${index}`)); + await this.click('#confirm-btn-yes'); + await this.page.waitForFunction((traitName) => { + const traitElements = document.querySelectorAll('[class*="js-trait-key-"]'); + for (const element of traitElements as any) { + if (element.textContent?.trim() === traitName) { + return false; + } + } + return true; + }, name, { timeout: LONG_TIMEOUT }); + } + + // Clone a segment + async cloneSegment(name: string, clonedName: string) { + const segmentRow = this.page.locator('.list-item').filter({ hasText: name }).first(); + const actionButton = segmentRow.locator('[data-test^="segment-action-"]'); + await actionButton.click(); + const cloneButton = segmentRow.locator('[data-test^="segment-clone-"]'); + await cloneButton.click(); + await this.setText('[name="clone-segment-name"]', clonedName); + await this.click('#confirm-clone-segment-btn'); + await this.page.locator('.list-item').filter({ hasText: clonedName }).first().waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + } + + // Delete a segment + async deleteSegment(name: string) { + const segmentRow = this.page.locator('.list-item').filter({ hasText: name }).first(); + const actionButton = segmentRow.locator('[data-test^="segment-action-"]'); + await actionButton.click(); + const removeButton = segmentRow.locator('[data-test^="segment-remove-"]'); + await removeButton.click(); + await this.setText('[name="confirm-segment-name"]', name); + await this.click('#confirm-remove-segment-btn'); + await this.waitForModalToClose(); + await expect(this.page.locator('.list-item').filter({ hasText: name })).toHaveCount(0, { timeout: LONG_TIMEOUT }); + } + + // Close modal + async closeModal() { + log('Close Modal'); + // Click top-left (on modal backdrop) to close + await this.page.mouse.click(10, 10); + await this.waitForElementNotExist('.modal-open'); + } + + // Save feature segments + async saveFeatureSegments() { + await this.click('#update-feature-segments-btn'); + await this.waitForToast(); + await this.closeModal(); + await this.waitForElementNotExist('#create-feature-modal'); + } + + // Wait and refresh + async waitAndRefresh(waitFor: number = 3000) { + await this.page.waitForTimeout(waitFor); + await this.page.reload(); + await this.waitForPageFullyLoaded(); + } + + // Create a role + async createRole(name: string, users: number[]) { + await this.click(byId('tab-item-roles')); + await this.click(byId('create-role')); + await this.setText(byId('role-name'), name); + await this.click(byId('save-role')); + await this.closeModal(); + // Wait for any toast messages to clear before continuing + await this.waitForToastsToClear(); + // Click on the role by its name + const roleRow = this.page.locator('[data-test^="role-"]').filter({ hasText: name }).first(); + await roleRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await roleRow.click(); + // Wait for modal to be visible before clicking tabs + await this.waitForElementVisible('.modal-open'); + await this.click(byId('members-tab')); + await this.click(byId('assigned-users')); + for (const userId of users) { + await this.click(byId(`assignees-list-item-${userId}`)); + } + await this.closeModal(); + } + + // Assert user feature value + async assertUserFeatureValue(name: string, expectedValue: string) { + const featureRow = this.page.locator('[data-test^="user-feature-"]', { hasText: name }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + const valueElement = featureRow.locator('[data-test^="user-feature-value-"]'); + await expect(valueElement).toHaveText(expectedValue, { timeout: LONG_TIMEOUT }); + } + + // Wait for user feature switch state + async waitForUserFeatureSwitch(name: string, state: 'on' | 'off') { + const featureRow = this.page.locator('[data-test^="user-feature-"]', { hasText: name }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + const switchElement = featureRow.locator(`[data-test^="user-feature-switch-"][data-test$="-${state}"]`); + await switchElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + } + + // Click user feature switch + async clickUserFeatureSwitch(name: string, state: 'on' | 'off') { + const featureRow = this.page.locator('[data-test^="user-feature-"]', { hasText: name }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + const switchElement = featureRow.locator(`[data-test^="user-feature-switch-"][data-test$="-${state}"]`).first(); + await switchElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await switchElement.dispatchEvent('click'); + } + + // Click user feature (to open edit modal) + async clickUserFeature(name: string) { + const featureRow = this.page.locator('[data-test^="user-feature-"]', { hasText: name }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await featureRow.dispatchEvent('click'); + } + + // Assert input value + async assertInputValue(selector: string, value: string) { + await expect(this.page.locator(selector)).toHaveValue(value); + } + + // Wait for feature switch state (in features list) + async waitForFeatureSwitch(name: string, state: 'on' | 'off') { + const featureRow = this.page.locator('[data-test^="feature-item-"]').filter({ + has: this.page.locator(`span:text-is("${name}")`) + }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + const switchElement = featureRow.locator(`[data-test^="feature-switch-"][data-test$="-${state}"]`); + await switchElement.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + } + + // Delete segment from the segment detail page + async deleteSegmentFromPage(name: string) { + await this.click(byId('remove-segment-btn')); + await this.setText('[name="confirm-segment-name"]', name); + await this.click('#confirm-remove-segment-btn'); + await this.waitForModalToClose(); + await this.waitForElementVisible(byId('show-create-segment-btn')); + } + + // Set segment rule + async setSegmentRule(ruleIndex: number, orIndex: number, name: string, operator: string, value: string | number | boolean) { + await this.setText(byId(`rule-${ruleIndex}-property-${orIndex}`), name); + if (operator) { + await this.setText(byId(`rule-${ruleIndex}-operator-${orIndex}`), operator); + await this.page.waitForTimeout(200); + } + await this.waitForElementVisible(byId(`rule-${ruleIndex}-value-${orIndex}`)); + await this.setText(byId(`rule-${ruleIndex}-value-${orIndex}`), `${value}`); + } + + // Create a segment + async createSegment(name: string, rules?: Rule[]) { + await this.click(byId('show-create-segment-btn')); + await this.setText(byId('segmentID'), name); + if (rules && rules.length > 0) { + for (let x = 0; x < rules.length; x++) { + const rule = rules[x]; + if (x > 0) { + await this.click(byId('add-rule')); + await this.waitForElementVisible(byId(`rule-${x}-property-0`)); + } + await this.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]; + await this.click(byId(`rule-${x}-or`)); + await this.waitForElementVisible(byId(`rule-${x}-property-${orIndex + 1}`)); + await this.setSegmentRule(x, orIndex + 1, or.name, or.operator, or.value); + } + } + } + } + await this.click(byId('create-segment')); + await this.waitForModalToClose(); + const element = this.page.locator('[data-test^="segment-"][data-test$="-name"]').filter({ hasText: name }); + await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + } + + // Open segment override dropdown and select a segment + private async openSegmentOverride(index: number, selectionIndex: number = 0) { + const dropdownSelector = byId(`select-segment-option-${selectionIndex}`); + const isDropdownVisible = await this.page.locator(dropdownSelector).isVisible().catch(() => false); + if (!isDropdownVisible) { + await this.click(byId('segment_overrides')); + } + await this.click(dropdownSelector); + await this.waitForElementVisible(byId(`segment-override-value-${index}`)); + } + + // Add segment override for boolean flags + async addSegmentOverride(index: number, value: boolean, selectionIndex: number = 0, mvs: MultiVariate[] = []) { + await this.openSegmentOverride(index, selectionIndex); + if (mvs && mvs.length > 0) { + for (const v of mvs) { + const weightSelector = `.segment-overrides ${byId(`featureVariationWeight${v.value}`)}`; + await this.waitForElementVisible(weightSelector); + await this.setText(weightSelector, `${v.weight}`); + await this.page.waitForTimeout(100); + } + await this.page.waitForTimeout(500); + } + if (value) { + await this.click(byId(`segment-override-toggle-${index}`)); + } + } + + // Add segment override for remote configs + async addSegmentOverrideConfig(index: number, value: string | number | boolean, selectionIndex: number = 0) { + await this.openSegmentOverride(index, selectionIndex); + await this.setText(byId(`segment-override-value-${index}`), `${value}`); + await this.click(byId(`segment-override-toggle-${index}`)); + } + + // Set segment override index (for reordering) + async setSegmentOverrideIndex(index: number, newIndex: number) { + await this.click(byId('segment_overrides')); + await this.setText(byId(`sort-${index}`), `${newIndex}`); + } + + // Edit remote config + async editRemoteConfig(featureName: string, value: string | number | boolean, toggleFeature: boolean = false, mvs: MultiVariate[] = []) { + await this.gotoFeatures(); + const featureRow = this.page.locator('[data-test^="feature-item-"]').filter({ + has: this.page.locator(`span:text-is("${featureName}")`) + }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await featureRow.dispatchEvent('click'); + if (value !== '') { + await this.setText(byId('featureValue'), `${value}`); + } + if (mvs.length > 0) { + await this.page.waitForTimeout(500); + for (const v of mvs) { + const selector = byId(`featureVariationWeight${v.value}`); + await this.waitForElementVisible(selector); + const input = this.page.locator(selector); + await input.clear(); + await this.page.waitForTimeout(100); + await input.fill(`${v.weight}`); + await this.page.waitForTimeout(100); + await input.blur(); + await this.page.waitForTimeout(500); + } + } + if (toggleFeature) { + await this.click(byId('toggle-feature-button')); + } + if (mvs.length > 0 || value !== '') { + await this.page.waitForTimeout(1500); + } + await this.click(byId('update-feature-btn')); + await this.waitForToast(); + await this.closeModal(); + await this.waitForElementNotExist('#create-feature-modal'); + } + + // Create an environment + async createEnvironment(name: string) { + await this.waitForElementVisible('[name="envName"]'); + const nameInput = this.page.locator('[name="envName"]').first(); + await nameInput.click(); + await nameInput.fill(name); + await this.click('#create-env-btn'); + await this.waitForElementVisible(byId(`switch-environment-${name.toLowerCase()}-active`)); + } + + // Create organisation and project + async createOrganisationAndProject(organisationName: string, projectName: string) { + log('Create Organisation'); + await this.click(byId('home-link')); + await this.click(byId('create-organisation-btn')); + await this.setText('[name="orgName"]', organisationName); + await this.click('#create-org-btn'); + await this.page.waitForURL(/\/organisation\/\d+/, { timeout: LONG_TIMEOUT }); + await this.waitForElementVisible(byId('project-manage-widget')); + await this.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 this.click('.btn-project-create'); + await this.setText(byId('projectName'), projectName); + await this.click(byId('create-project-btn')); + await this.waitForElementVisible(byId('features-page')); + } + + // Get text content of an element + async getText(selector: string): Promise { + return await this.page.locator(selector).innerText(); + } + + // Parse try it results + async parseTryItResults(): Promise> { + const text = await this.getText('#try-it-results'); + try { + return JSON.parse(text); + } catch (e) { + throw new Error('Try it results are not valid JSON'); + } + } + + // Go to feature versions + async goToFeatureVersions(featureName: string) { + await this.gotoFeature(featureName); + if (await this.isElementExists('change-history')) { + await this.click(byId('change-history')); + } else { + await this.click(byId('tabs-overflow-button')); + await this.click(byId('change-history')); + } + } + + // Compare version + async compareVersion( + featureName: string, + versionIndex: number, + compareOption: 'LIVE' | 'PREVIOUS' | null, + oldEnabled: boolean, + newEnabled: boolean, + oldValue?: any, + newValue?: any, + ) { + await this.goToFeatureVersions(featureName); + await this.click(byId(`history-item-${versionIndex}-compare`)); + if (compareOption === 'LIVE') { + await this.click(byId(`history-item-${versionIndex}-compare-live`)); + } else if (compareOption === 'PREVIOUS') { + await this.click(byId(`history-item-${versionIndex}-compare-previous`)); + } + + // Wait for comparison modal to fully load data + await this.page.waitForTimeout(2000); + + // Use .first() to handle cases where multiple comparison modals might exist in DOM + await expect(this.page.locator(byId('old-enabled')).first()).toHaveText(`${oldEnabled}`); + await expect(this.page.locator(byId('new-enabled')).first()).toHaveText(`${newEnabled}`); + if (oldValue !== undefined) { + const expectedOldValue = oldValue === null ? '' : `${oldValue}`; + await expect(this.page.locator(byId('old-value')).first()).toHaveText(expectedOldValue); + } + if (newValue !== undefined) { + const expectedNewValue = newValue === null ? '' : `${newValue}`; + await expect(this.page.locator(byId('new-value')).first()).toHaveText(expectedNewValue); + } + await this.closeModal(); + } + + // Assert number of versions + async assertNumberOfVersions(featureName: string, versions: number) { + await this.goToFeatureVersions(featureName); + await this.waitForElementVisible(byId(`history-item-${versions - 2}-compare`)); + await this.closeModal(); + } + + // Get feature index by name + async getFeatureIndexByName(featureName: string): Promise { + const featureRow = this.page.locator('[data-test^="feature-item-"]').filter({ + has: this.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 a feature's action button (3-dot menu) and wait for the dropdown to open. + * + * This helper includes retry logic to handle a race condition in Firefox where + * clicking the action button sometimes fails to open the dropdown menu. The issue + * occurs due to a timing conflict between: + * 1. Playwright's scroll-into-view behavior causing element instability + * 2. The useOutsideClick hook listening for mouseup events + * 3. React's state update after the button click + * + * When the element is "not stable" (moving due to scroll), Playwright waits and + * retries. However, there's a small timing window where the click can complete + * successfully (React receives the click event) but the dropdown immediately + * closes due to a spurious outside-click detection. + * + * The retry mechanism works around this by: + * 1. Attempting to click the action button + * 2. Waiting briefly for the dropdown to appear + * 3. If the dropdown doesn't appear, retrying the click (up to maxRetries times) + */ + async clickFeatureAction(featureName: string, maxRetries: number = 3): Promise { + const index = await this.getFeatureIndexByName(featureName); + const actionButtonSelector = byId(`feature-action-${index}`); + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + logUsingLastSection(`Clicking feature action button (attempt ${attempt}/${maxRetries})`); + + await this.click(actionButtonSelector); + + try { + // Use a shorter timeout for the dropdown check since we'll retry if it fails + await this.page.locator('.feature-action__list').first().waitFor({ + state: 'visible', + timeout: 2000 + }); + // Dropdown appeared, we're done + return; + } catch { + if (attempt === maxRetries) { + // Final attempt failed, throw with helpful context + throw new Error( + `Feature action dropdown for "${featureName}" did not open after ${maxRetries} attempts. ` + + `This may indicate a race condition with the useOutsideClick hook.` + ); + } + // Dropdown didn't appear, wait a moment before retrying + logUsingLastSection(`Dropdown did not appear, retrying...`); + await this.page.waitForTimeout(100); + } + } + } + + // Wait for feature switch by name and state to be clickable or not clickable + async waitForFeatureSwitchClickable(featureName: string, state: 'on' | 'off', clickable: boolean = true) { + const featureRow = this.page.locator('[data-test^="feature-item-"]').filter({ + has: this.page.locator(`span:text-is("${featureName}")`) + }).first(); + + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + 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 }); + } + } + + // Set user permission + async setUserPermission( + email: string, + permission: string, + entityName: string | null, + entityLevel?: 'project' | 'environment' | 'organisation', + parentName?: string, + ) { + const permissionsMap: Record = { + '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', + }; + await this.click(byId('users-and-permissions')); + await this.click(byId(`user-${email}`)); + const level = permissionsMap[permission] || entityLevel; + await this.click(byId(`${level}-permissions-tab`)); + if (parentName) { + await this.clickByText(parentName, 'a'); + } + if (entityName) { + await this.click(byId(`permissions-${entityName.toLowerCase()}`)); + } + if (permission === 'ADMIN') { + await this.click(byId(`admin-switch-${level}`)); + } else { + await this.click(byId(`permission-switch-${permission}`)); + } + await this.closeModal(); + } + + // Create a tag + async createTag(label: string, color: string = '#FF6B6B') { + logUsingLastSection(`Creating tag: ${label}`); + // Open a feature modal to access tag creation + await this.click('#show-create-feature-btn'); + await this.waitForElementVisible('#create-feature-modal'); + + // Click the "Add Tag" button to open tag interface + const addTagButton = this.page.locator('button').filter({ hasText: 'Add Tag' }); + await addTagButton.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await addTagButton.scrollIntoViewIfNeeded(); + await addTagButton.click(); + + // Wait for either the create tag modal or the "Add New Tag" button + const addNewTagButton = this.page.locator('button').filter({ hasText: 'Add New Tag' }); + const tagLabelInput = this.page.locator(byId('tag-label')); + + // Wait for one of them to appear + await Promise.race([ + addNewTagButton.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {}), + tagLabelInput.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {}) + ]); + + // If "Add New Tag" button is visible, click it + const hasAddNewTagButton = await addNewTagButton.isVisible().catch(() => false); + if (hasAddNewTagButton) { + await addNewTagButton.click(); + } + + // Fill in tag details + await this.setText(byId('tag-label'), label); + await this.page.waitForTimeout(300); + + // Click the first available color + const firstColor = this.page.locator('.tag--select').first(); + await firstColor.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await firstColor.click(); + await this.page.waitForTimeout(300); + + // Save the tag + const saveButton = this.page.locator('button').filter({ hasText: 'Save Tag' }); + await saveButton.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await expect(saveButton).toBeEnabled({ timeout: LONG_TIMEOUT }); + await saveButton.click(); + await this.page.waitForTimeout(1000); + + // Close the modals by clicking outside + await this.closeModal(); + } + + // Add a tag to a feature (must be called when feature modal is open) + async addTagToFeature(tagLabel: string) { + logUsingLastSection(`Adding tag to feature: ${tagLabel}`); + + // Wait for feature modal to be visible + await this.waitForElementVisible('#create-feature-modal'); + + // Navigate to Settings tab + const settingsTab = this.page.locator('[data-test="settings"]'); + const isSettingsVisible = await settingsTab.isVisible().catch(() => false); + if (isSettingsVisible) { + await settingsTab.click(); + await this.page.waitForTimeout(500); + } + + // Click the "Add Tag" button to open tag selection + const addTagButton = this.page.locator('button').filter({ hasText: 'Add Tag' }); + await addTagButton.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await addTagButton.scrollIntoViewIfNeeded(); + await addTagButton.click(); + + // Wait for tag list to appear + const tagList = this.page.locator('.tag-list'); + await tagList.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Find and click the tag using JavaScript to bypass visibility checks + await this.page.evaluate((label) => { + const tagList = document.querySelector('.tag-list'); + if (!tagList) return false; + + // Find the element containing the tag text + const elements = Array.from(tagList.querySelectorAll('*')); + const tagElement = elements.find(el => + el.textContent?.trim() === label || el.textContent?.includes(label) + ); + + if (tagElement) { + // Scroll it into view within the container + tagElement.scrollIntoView({ block: 'center', behavior: 'auto' }); + + // Find the clickable parent (usually has cursor:pointer or is a checkbox) + let clickable = tagElement; + let current = tagElement; + while (current && current !== tagList) { + const style = window.getComputedStyle(current); + if (style.cursor === 'pointer' || current.tagName === 'INPUT') { + clickable = current; + break; + } + current = current.parentElement; + } + + // Click it + clickable.click(); + return true; + } + return false; + }, tagLabel); + } + + // Archive a feature (must be called when feature modal is open) + async archiveFeature() { + logUsingLastSection('Archiving feature'); + + // Wait for feature modal to be visible + await this.waitForElementVisible('#create-feature-modal'); + + // Navigate to Settings tab if not already there + const settingsTab = this.page.locator('[data-test="settings"]'); + const isVisible = await settingsTab.isVisible().catch(() => false); + if (isVisible) { + await settingsTab.click(); + await this.page.waitForTimeout(500); + } + + // Find the switch button with role="switch" near the "Archived" text + const archiveSwitch = this.page.locator('button[role="switch"]').filter({ + has: this.page.locator('text=/Archived/i') + }).or( + this.page.locator('.setting').filter({ hasText: /Archived/i }).locator('button[role="switch"]') + ).first(); + + await archiveSwitch.scrollIntoViewIfNeeded(); + await archiveSwitch.click(); + + // Save the feature settings - use the visible Update button + const updateButton = this.page.locator(byId('update-feature-btn')).filter({ hasText: 'Update Settings' }); + await updateButton.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await updateButton.click(); + } + + // Navigate to a project by name + // Navigate to change requests page + async gotoChangeRequests() { + log('Navigate to change requests'); + await this.click('#change-requests-link'); + } + + // Create a change request from feature modal + async createChangeRequest(title: string, description: string) { + log(`Create change request: ${title}`); + + // Click the update/create change request button + // When 4-eyes is enabled, this button says "Create Change Request" + await this.click('#update-feature-btn'); + await this.page.waitForTimeout(1000); + + // Fill in title using placeholder + const titleField = this.page.locator('input[placeholder="My Change Request"]'); + await titleField.waitFor({ state: 'visible' }); + await titleField.fill(title); + + // Fill in description using placeholder + const descField = this.page.locator('textarea[placeholder="Add an optional description..."]'); + await descField.fill(description); + + // The date picker needs to be set - click on it to trigger current date/time + // Find the date input and click it + const dateInput = this.page.locator('.react-datepicker__input-container input').first(); + await dateInput.click(); + await this.page.waitForTimeout(300); + + // Click "Now" or today's date to set it + // The datepicker should appear - click on today + const todayButton = this.page.locator('.react-datepicker__today-button, .react-datepicker__day--today').first(); + await todayButton.click(); + await this.page.waitForTimeout(500); + + // Click create/save button - look for enabled button + const saveButton = this.page.locator('button').filter({ hasText: /Save|Create/ }).filter({ hasNotText: 'Cancel' }).last(); + await saveButton.waitFor({ state: 'visible' }); + + // Wait for button to be enabled + await expect(saveButton).toBeEnabled({ timeout: 5000 }); + await saveButton.click(); + + await this.waitForToast(); + } + + // Open a change request from the list + async openChangeRequest(index: number = 0) { + log(`Open change request at index ${index}`); + await this.waitForElementVisible('.list-item.clickable'); + const changeRequestItem = this.page.locator('.list-item.clickable').nth(index); + await changeRequestItem.click(); + } + + // Approve a change request + async approveChangeRequest() { + log('Approve change request'); + await this.click(byId('approve-change-request-btn')); + // Wait for button state to change to verify approval + await this.page.locator('button:has-text("Approved")').waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + } + + // Publish a change request + async publishChangeRequest() { + log('Publish change request'); + await this.click(byId('publish-change-request-btn')); + await this.click('#confirm-btn-yes'); // Confirm publish + // Wait for "Committed at" text to appear to verify publish succeeded + await this.page.locator('text=/Committed at/').waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + } + + // Enable change requests for an environment + async enableChangeRequests(minimumApprovals: number = 1) { + log(`Enable change requests with ${minimumApprovals} approval(s) for environment`); + + // Navigate to environment settings + await this.click('#env-settings-link'); + await this.page.waitForTimeout(500); + + // Wait for the settings page to load + await this.waitForElementVisible('h5:has-text("Feature Change Requests")'); + + // Get all visible switches - Feature Change Requests should be the last visible one + const allSwitches = this.page.locator('button[role="switch"]:visible'); + const switchCount = await allSwitches.count(); + log(`Found ${switchCount} visible switches on page`); + + const changeRequestToggle = allSwitches.last(); + + // Check if it's already on by checking aria-checked attribute + const isChecked = await changeRequestToggle.getAttribute('aria-checked'); + log(`Change request toggle aria-checked: "${isChecked}"`); + + // Click if it's off + if (isChecked !== 'true') { + log('Clicking change request toggle to turn ON'); + await changeRequestToggle.scrollIntoViewIfNeeded(); + await changeRequestToggle.click({ force: true }); + await this.page.waitForTimeout(2000); + + // Log new state + const newChecked = await changeRequestToggle.getAttribute('aria-checked'); + log(`After click, aria-checked: "${newChecked}"`); + } else { + log('Toggle already ON, skipping click'); + } + + // Set minimum approvals - the input appears after toggling on + const approvalInput = this.page.locator('input[placeholder="Minimum number of approvals"]'); + await approvalInput.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await approvalInput.fill(minimumApprovals.toString()); + + // Save environment settings + await this.click('#save-env-btn'); + await this.waitForToast(); + await this.page.waitForTimeout(1000); + } + + // Verify change request count + async assertChangeRequestCount(count: number) { + log(`Assert change request count: ${count}`); + if (count === 0) { + await this.page.waitForTimeout(1000); + const changeRequests = this.page.locator('.change-request-item'); + await expect(changeRequests).toHaveCount(0); + } else { + await this.waitForElementVisible('.change-request-item'); + const changeRequests = this.page.locator('.change-request-item'); + await expect(changeRequests).toHaveCount(count); + } + } +} + +// Export a factory function to create helpers for a page +// Returns an object with all methods bound to the instance so destructuring works +export function createHelpers(page: Page): E2EHelpers { + const instance = new E2EHelpers(page); + // Auto-bind all methods so destructuring works + const proto = Object.getPrototypeOf(instance); + const methodNames = Object.getOwnPropertyNames(proto).filter( + name => name !== 'constructor' && typeof (instance as any)[name] === 'function' + ); + const helpers: Record = {}; + for (const name of methodNames) { + helpers[name] = (instance as any)[name].bind(instance); + } + return helpers as E2EHelpers; +} + diff --git a/frontend/e2e/helpers/index.ts b/frontend/e2e/helpers/index.ts new file mode 100644 index 000000000000..2b52a0994891 --- /dev/null +++ b/frontend/e2e/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './utils.playwright'; +export * from './browser-logging.playwright'; +export * from './e2e-helpers.playwright'; diff --git a/frontend/e2e/helpers/utils.playwright.ts b/frontend/e2e/helpers/utils.playwright.ts new file mode 100644 index 000000000000..60feefff1358 --- /dev/null +++ b/frontend/e2e/helpers/utils.playwright.ts @@ -0,0 +1,41 @@ +import fetch from 'node-fetch'; +import flagsmith from 'flagsmith/isomorphic'; +import { IFlagsmith } from 'flagsmith/types'; +import Project from '../../common/project'; + +export const LONG_TIMEOUT = 20000; + +export const byId = (id: string) => `[data-test="${id}"]`; + +// 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); + } +}; + +// 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; +}; 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..0fc20850d482 --- /dev/null +++ b/frontend/e2e/run-with-retry.ts @@ -0,0 +1,255 @@ +import { execSync } from 'child_process'; +import { runTeardown } from './teardown'; +import * as fs from 'fs'; +import * as path from 'path'; + +require('dotenv').config(); + +const RETRIES = parseInt(process.env.E2E_RETRIES || '1', 10); +const REPEAT = parseInt(process.env.E2E_REPEAT || '0', 10); +const RESULTS_DIR = path.join(__dirname, 'test-results'); +const RESULTS_FILE = path.join(RESULTS_DIR, 'results.json'); + +function runPlaywright(args: string[], quietMode: boolean, isRetry: boolean, attemptNumber: number): 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; + } +} + +function mergeResults(attemptFiles: string[]): void { + if (attemptFiles.length === 0) return; + + // Read all results files + const allResults = attemptFiles.map(file => { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + console.error(`Warning: Failed to read ${file}`); + return null; + } + }).filter(r => r !== null); + + if (allResults.length === 0) return; + + // Use the first result as the base + const merged = allResults[0]; + + // Merge suites and tests from subsequent attempts + for (let i = 1; i < allResults.length; i++) { + const result = allResults[i]; + + // Merge test suites + if (result.suites) { + merged.suites = merged.suites || []; + result.suites.forEach((suite: any) => { + // Check if suite already exists + const existingIndex = merged.suites.findIndex((s: any) => s.file === suite.file && s.title === suite.title); + if (existingIndex >= 0) { + // Merge specs from this suite + const existingSuite = merged.suites[existingIndex]; + suite.specs?.forEach((spec: any) => { + const specIndex = existingSuite.specs?.findIndex((s: any) => s.title === spec.title); + if (specIndex >= 0) { + // Replace the spec (retry succeeded, use new result) + existingSuite.specs[specIndex] = spec; + } else { + // Add new spec + existingSuite.specs = existingSuite.specs || []; + existingSuite.specs.push(spec); + } + }); + } else { + // Add new suite + merged.suites.push(suite); + } + }); + } + } + + // Recalculate stats + let totalTests = 0; + let passed = 0; + let failed = 0; + let flaky = 0; + let skipped = 0; + + merged.suites?.forEach((suite: any) => { + suite.specs?.forEach((spec: any) => { + spec.tests?.forEach((test: any) => { + totalTests++; + if (test.status === 'expected') passed++; + else if (test.status === 'unexpected') failed++; + else if (test.status === 'flaky') flaky++; + else if (test.status === 'skipped') skipped++; + }); + }); + }); + + // Update stats + merged.stats = { + ...merged.stats, + expected: passed, + unexpected: failed, + flaky: flaky, + skipped: skipped, + }; + + // Write merged results + fs.writeFileSync(RESULTS_FILE, JSON.stringify(merged, null, 2)); +} + +async function main() { + let attempt = 0; + const attemptFiles: string[] = []; + + // 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 bundle', { stdio: quietMode ? 'ignore' : 'inherit', env: { ...process.env, E2E: 'true' } }); + } 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, attempt); + + // Save results from this attempt if retries are enabled + if (RETRIES > 0 && fs.existsSync(RESULTS_FILE)) { + const attemptFile = path.join(RESULTS_DIR, `results-attempt-${attempt}.json`); + fs.copyFileSync(RESULTS_FILE, attemptFile); + attemptFiles.push(attemptFile); + } + + 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'); + } + + // If REPEAT is set and this is the first successful run, repeat the tests + if (REPEAT > 0 && attempt === 0) { + if (!quietMode) { + console.log('\n=========================================='); + console.log(`Tests passed! Running ${REPEAT} additional time(s) to check for flakiness...`); + console.log('==========================================\n'); + } + + for (let repeatAttempt = 1; repeatAttempt <= REPEAT; repeatAttempt++) { + if (!quietMode) { + console.log(`\nRepeat attempt ${repeatAttempt} of ${REPEAT}...`); + } + + // Run the same tests again with the original arguments + const repeatSuccess = runPlaywright(extraArgs, quietMode, false, 0); + + if (!repeatSuccess) { + if (!quietMode) { + console.log('\n=========================================='); + console.log(`FLAKY TEST DETECTED: Tests failed on repeat attempt ${repeatAttempt} of ${REPEAT}`); + console.log('==========================================\n'); + } + process.exit(1); + } + + if (!quietMode) { + console.log(`Repeat attempt ${repeatAttempt} of ${REPEAT} passed`); + } + } + + if (!quietMode) { + console.log('\n=========================================='); + console.log(`All tests passed ${REPEAT + 1} time(s) - no flakiness detected!`); + console.log('==========================================\n'); + } + } + + // Merge results from all attempts if we had retries + if (RETRIES > 0 && attemptFiles.length > 1) { + if (!quietMode) console.log('Merging test results from all attempts...'); + mergeResults(attemptFiles); + // Clean up attempt files + attemptFiles.forEach(file => { + try { + fs.unlinkSync(file); + } catch (error) { + // Ignore cleanup errors + } + }); + } + + 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/slack-e2e-reporter.ts b/frontend/e2e/slack-e2e-reporter.ts new file mode 100644 index 000000000000..c292d46b4963 --- /dev/null +++ b/frontend/e2e/slack-e2e-reporter.ts @@ -0,0 +1,111 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { WebClient } from '@slack/web-api'; + +const SLACK_TOKEN = process.env.SLACK_TOKEN; +const CHANNEL_ID = 'C0102JZRG3G'; // infra_tests channel ID +const failedJsonPath = path.join(__dirname, 'test-results', 'failed.json'); +const failedData = JSON.parse(fs.readFileSync(failedJsonPath, 'utf-8')); +const failedCount = failedData.failedTests?.length || 0; + +async function uploadFile(filePath: string): Promise { + if (!SLACK_TOKEN) { + console.log('Slack token not specified, skipping upload'); + return; + } + + const epoch = Date.now(); + const filename = `playwright-report-${epoch}.zip`; + + console.log(`Uploading ${filePath}`); + + const slackClient = new WebClient(SLACK_TOKEN); + await slackClient.files.uploadV2({ + channel_id: CHANNEL_ID, + file: fs.createReadStream(filePath), + filename, + }); +} + +function postMessage(message: string): Promise { + if (!SLACK_TOKEN) { + console.log('Slack token not specified, skipping message'); + return Promise.resolve(); + } + + const slackClient = new WebClient(SLACK_TOKEN); + return slackClient.chat.postMessage({ + channel: CHANNEL_ID, + text: message, + }); +} + +function notifyFailure( + failedCount: number, + failedTests: any[], +): Promise { + const actionUrl = process.env.GITHUB_ACTION_URL || ''; + if (!actionUrl) { + console.log('No GITHUB_ACTION_URL set, skipping Slack notification'); + return Promise.resolve(); + } + + const branch = process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || 'unknown'; + const testType = process.env.TEST_TYPE || 'unknown'; + const prNumber = process.env.PR_NUMBER; + const prTitle = process.env.PR_TITLE; + const prUrl = process.env.PR_URL; + + // Build PR info line + const prInfo = prNumber && prUrl + ? `*PR:* <${prUrl}|#${prNumber}>${prTitle ? ` - ${prTitle}` : ''}\n` + : ''; + + // Build failed tests list (inline, limit to first 3) + const testNames = failedTests + .slice(0, 3) + .map((test) => test.title) + .join(', '); + const moreTests = failedCount > 3 ? ` +${failedCount - 3} more` : ''; + + const message = `❌ E2E Tests Failed + +${prInfo}*Branch:* ${branch} +*Test Type:* ${testType} +*Failed:* ${failedCount} test(s) - ${testNames}${moreTests} + +📦 View artifacts: ${actionUrl}`; + + return postMessage(message); +} + +if (!fs.existsSync(failedJsonPath)) { + console.log('No failed.json found, skipping Slack notification'); + process.exit(0); +} + +if (failedCount === 0) { + console.log('No failed tests, skipping Slack notification'); + process.exit(0); +} + +async function main() { + console.log(`Sending Slack notification for ${failedCount} failed test(s)...`); + await notifyFailure(failedCount, failedData.failedTests || []); + console.log('Slack notification sent successfully'); + + // Upload HTML report if zip file exists + const reportZipPath = path.join(__dirname, 'playwright-report.zip'); + if (fs.existsSync(reportZipPath)) { + console.log('Uploading HTML report...'); + await uploadFile(reportZipPath); + console.log('HTML report uploaded successfully'); + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error('Failed to send Slack notification:', error); + process.exit(0); + }); diff --git a/frontend/e2e/teardown.ts b/frontend/e2e/teardown.ts new file mode 100644 index 000000000000..e38a31721eab --- /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()}`]; + console.log(`Teardown target host ${e2eTestApi}`) + 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..e6da9ea6a263 --- /dev/null +++ b/frontend/e2e/test-setup.ts @@ -0,0 +1,11 @@ +import { test as base, expect } from '@playwright/test'; +import { setupBrowserLogging } from './helpers'; + +const test = base.extend<{ e2eSetup: void }>({ + e2eSetup: [async ({ page }, use) => { + setupBrowserLogging(page); + await use(); + }, { auto: true }], +}); + +export { test, expect }; diff --git a/frontend/e2e/tests/change-request-test.pw.ts b/frontend/e2e/tests/change-request-test.pw.ts new file mode 100644 index 000000000000..562e0a8d1407 --- /dev/null +++ b/frontend/e2e/tests/change-request-test.pw.ts @@ -0,0 +1,143 @@ +import { test, expect } from '../test-setup' +import { byId, getFlagsmith, log, createHelpers } from '../helpers' +import { + E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, + E2E_TEST_PROJECT, + E2E_USER, + PASSWORD, +} from '../config' + +test.describe('Change Request Tests', () => { + test('Change requests can be created, approved, and published with 4-eyes approval @enterprise', async ({ + page, + }) => { + const { + assertChangeRequestCount, + approveChangeRequest, + assertInputValue, + closeModal, + createChangeRequest, + createEnvironment, + createRemoteConfig, + enableChangeRequests, + gotoChangeRequests, + gotoFeature, + gotoFeatures, + gotoProject, + login, + logout, + openChangeRequest, + parseTryItResults, + publishChangeRequest, + setText, + setUserPermission, + waitForElementVisible, + } = createHelpers(page) + + const flagsmith = await getFlagsmith() + const hasChangeRequests = flagsmith.hasFeature('segment_change_requests') + + if (!hasChangeRequests) { + log('Skipping change request test, feature not enabled.') + test.skip() + return + } + + const projectName = E2E_TEST_PROJECT + const environmentName = 'CR_Test_Env' + const featureName = 'cr_test_feature' + + log('Login as admin') + await login(E2E_USER, PASSWORD) + + log('Navigate to test project') + await gotoProject(projectName) + + log('Create test environment') + await page.click('text="Create Environment"') + await createEnvironment(environmentName) + + log('Enable change requests for test environment') + await enableChangeRequests(1) + + log('Create initial feature') + await createRemoteConfig({ + name: featureName, + value: 'initial_value', + }) + + log('Verify initial value via API') + await page.click('#try-it-btn') + let json = await parseTryItResults() + expect(json[featureName].value).toBe('initial_value') + + log('Create change request by editing feature value') + await gotoFeatures() + await gotoFeature(featureName) + await setText(byId('featureValue'), 'updated_value') + + await createChangeRequest( + 'Update feature value', + 'Updating value from initial_value to updated_value', + ) + + log('Verify change request was created') + await closeModal() + + log('Verify value has NOT changed yet (change request not approved)') + await page.click('#try-it-btn') + json = await parseTryItResults() + expect(json[featureName].value).toBe('initial_value') // Still old value + + log('Grant approver project ADMIN permission') + await page.click('a:has-text("Bullet Train Ltd")') + await setUserPermission( + E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, + 'ADMIN', + projectName, + 'project' + ) + + log('Logout and login as approver') + await logout() + await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + + log('Navigate to project') + await gotoProject(projectName) + + log(`Switch to ${environmentName} environment`) + await page.click(`text="${environmentName}"`) + + log('Go to change requests') + await gotoChangeRequests() + + log('Open change request') + await openChangeRequest(0) + + log('Approve change request') + await approveChangeRequest() + + log('Publish change request') + await publishChangeRequest() + + log('Close modal') + await closeModal() + + log('Verify value has NOW changed (change request published)') + await gotoFeatures() + await gotoFeature(featureName) + await assertInputValue(byId('featureValue'), 'updated_value') + await closeModal() + + log('Verify value via API') + await page.click('#try-it-btn') + json = await parseTryItResults() + expect(json[featureName].value).toBe('updated_value') + + log('Verify change request is no longer in list') + await gotoChangeRequests() + await assertChangeRequestCount(0) + + log('Change request test completed successfully') + }) +}) 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..7bc3a5b56ee3 --- /dev/null +++ b/frontend/e2e/tests/environment-permission-test.pw.ts @@ -0,0 +1,168 @@ +import { test, expect } from '../test-setup'; +import { + byId, + log, + createHelpers, +} from '../helpers'; +import { + PASSWORD, + E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, + E2E_USER, + E2E_TEST_IDENTITY, + E2E_PROJECT_WITH_ENV_PERMISSIONS, +} from '../config'; + +test.describe('Environment Permission Tests', () => { + test('Environment-level permissions control access to features, identities, and segments @enterprise', async ({ page }) => { + const { + click, + clickByText, + closeModal, + createEnvironment, + createFeature, + editRemoteConfig, + gotoFeature, + gotoProject, + gotoTraits, + login, + logout, + setUserPermission, + toggleFeature, + waitForElementClickable, + waitForElementNotClickable, + waitForElementNotExist, + waitForElementVisible, + waitForFeatureSwitchClickable, + } = createHelpers(page); + + const PROJECT_NAME = E2E_PROJECT_WITH_ENV_PERMISSIONS; + + log('Login') + await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + log('User can only view project') + await gotoProject(PROJECT_NAME) + await expect(page.locator('#project-select-1')) + .not.toBeVisible({ timeout: 5000 }) + await logout() + + log('User with permissions can Handle the Features') + await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await createFeature({ name: 'test_feature', value: false }) + await toggleFeature('test_feature', true) + await logout() + + log('User without permissions cannot create traits') + await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await gotoTraits(E2E_TEST_IDENTITY) + const createTraitBtn = page.locator(byId('add-trait')) + await expect(createTraitBtn).toBeDisabled() + await logout() + + log('User without permissions cannot see audit logs') + await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await waitForElementNotExist(byId('audit-log-link')) + await logout() + + log('Create new environment') + await login(E2E_USER, PASSWORD) + await gotoProject(PROJECT_NAME) + await waitForElementVisible(byId('switch-environment-development')) + 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 gotoProject(PROJECT_NAME) + 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', PROJECT_NAME ) + await logout() + log('User with permissions can see environment') + await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + 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 gotoProject(PROJECT_NAME) + await createFeature({ name: 'my_feature', value: 'foo', description: 'A test feature' }) + await editRemoteConfig('my_feature', 'bar') + await logout() + log('User without permission cannot create a segment override') + await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await gotoFeature('my_feature') + 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', PROJECT_NAME ) + await logout() + log('User with permission can create a segment override') + await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await gotoFeature('my_feature') + 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 gotoProject(PROJECT_NAME) + await waitForFeatureSwitchClickable('test_feature', 'on', true) + await click(byId('switch-environment-production')) + await waitForFeatureSwitchClickable('test_feature', 'off', false) + await gotoFeature('test_feature') + 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 gotoProject(PROJECT_NAME) + 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 gotoProject(PROJECT_NAME) + await click('#users-link') + await page.locator('[data-test^="user-item-"]').filter({ hasText: E2E_TEST_IDENTITY }).first().click() + 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', PROJECT_NAME ) + await logout() + log('User with permissions can add user trait') + await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await click('#users-link') + await page.locator('[data-test^="user-item-"]').filter({ hasText: E2E_TEST_IDENTITY }).first().click() + 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', PROJECT_NAME ) + await logout() + log('User without permissions cannot view identities') + await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await click('#users-link') + await waitForElementVisible(byId('missing-view-identities')) + }); +}); 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..881e48595d4b --- /dev/null +++ b/frontend/e2e/tests/environment-test.pw.ts @@ -0,0 +1,34 @@ +import { test, expect } from '../test-setup'; +import { byId, log, createHelpers } from '../helpers'; +import { PASSWORD, E2E_USER, E2E_TEST_PROJECT } from '../config' + +test.describe('Environment Tests', () => { + test('Environments can be created, renamed, and deleted @oss', async ({ page }) => { + const { + click, + createEnvironment, + gotoProject, + login, + setText, + waitForElementVisible, + } = createHelpers(page); + + log('Login') + await login(E2E_USER, PASSWORD) + await gotoProject(E2E_TEST_PROJECT) + await waitForElementVisible(byId('switch-environment-development')) + 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')) + }); +}); \ 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..79e6fb481dee --- /dev/null +++ b/frontend/e2e/tests/flag-tests.pw.ts @@ -0,0 +1,196 @@ +import { test, expect } from '../test-setup'; +import { byId, log, createHelpers } from '../helpers'; +import { E2E_USER, PASSWORD, E2E_TEST_PROJECT } from '../config'; + +test.describe('Flag Tests', () => { + test('Feature flags can be created, toggled, edited, and deleted across environments @oss', async ({ page }) => { + const { + click, + createFeature, + createRemoteConfig, + deleteFeature, + editRemoteConfig, + gotoFeatures, + gotoProject, + login, + parseTryItResults, + scrollBy, + toggleFeature, + waitForElementClickable, + waitForElementVisible, + waitForFeatureSwitch, + } = createHelpers(page); + + log('Login') + await login(E2E_USER, PASSWORD) + await gotoProject(E2E_TEST_PROJECT) + + // 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 waitForElementClickable(byId('switch-environment-development')) + await click(byId('switch-environment-development')) + await page.waitForTimeout(500) + } + + log('Create Features') + await click('#features-link') + + await createFeature({ name: 'header_enabled', value: false }) + await createRemoteConfig({ name: 'header_size', value: 'big' }) + await createRemoteConfig({ name: 'mv_flag', value: 'big', mvs: [ + { value: 'medium', weight: 100 }, + { value: 'small', weight: 0 }, + ]}) + + log('Create Short Life Feature') + await createFeature({ name: 'short_life_feature', value: false }) + await scrollBy(0, 15000) + + log('Delete Short Life Feature') + await deleteFeature('short_life_feature') + await scrollBy(0, 30000) + + log('Toggle Feature') + await toggleFeature('header_enabled', true) + + log('Try it') + await page.waitForTimeout(2000) + await click('#try-it-btn') + await page.waitForTimeout(500) + let json = await parseTryItResults() + 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('header_size', 12) + + log('Try it again') + await page.waitForTimeout(5000) + await click('#try-it-btn') + await page.waitForTimeout(2000) + json = await parseTryItResults() + await expect(json.header_size.value).toBe(12) + + log('Change feature value to boolean') + await editRemoteConfig('header_size', false) + + log('Try it again 2') + await page.waitForTimeout(5000) + await click('#try-it-btn') + await page.waitForTimeout(2000) + json = await parseTryItResults() + await expect(json.header_size.value).toBe(false) + + log('Switch environment') + // Navigate back to features list so environment switcher is visible in navbar + await gotoFeatures() + // Wait for page to be fully loaded and features page to be ready + await page.waitForLoadState('load') + await 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 waitForElementClickable(byId('switch-environment-production')) + await click(byId('switch-environment-production')) + + log('Feature should be off under different environment') + await waitForElementVisible(byId('switch-environment-production-active')) + await waitForFeatureSwitch('header_enabled', 'off') + + log('Clear down features') + // Ensure features list is fully loaded before attempting to delete + await waitForFeatureSwitch('header_enabled', 'off') + await deleteFeature('header_size') + await deleteFeature('header_enabled') + }); + + test('Feature flags can have tags added and be archived @oss', async ({ page }) => { + const { + addTagToFeature, + archiveFeature, + click, + clickByText, + closeModal, + createFeature, + createTag, + deleteFeature, + gotoFeature, + gotoFeatures, + gotoProject, + login, + waitForToast, + } = createHelpers(page); + + log('Login') + await login(E2E_USER, PASSWORD) + await gotoProject(E2E_TEST_PROJECT) + + log('Create Tags') + // Navigate to features first to ensure we're in the right context + await gotoFeatures() + + // Create first tag + await createTag('bug', '#FF6B6B') + + // Create second tag + await createTag('feature-request', '#4ECDC4') + + log('Create Feature with Settings') + await createFeature({ name: 'test_flag_with_tags', value: true, description: 'Test flag for tag and archive operations' }) + + log('Create additional feature to keep filters visible') + await createFeature({ name: 'keep_filters_visible', value: false }) + + log('Open Feature and Add Tags') + await gotoFeature('test_flag_with_tags') + await addTagToFeature('bug') + await addTagToFeature('feature-request') + + // Save the feature settings + await clickByText('Update Settings'); + await closeModal() + + log('Archive Feature') + await gotoFeature('test_flag_with_tags') + await archiveFeature() + await waitForToast() + + log('Verify Archive') + await closeModal() + + // Verify the feature can be filtered as archived + await gotoFeatures() + + // Verify archived feature is not visible by default + const archivedFeatureHidden = await page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("test_flag_with_tags")`) + }).count() + expect(archivedFeatureHidden).toBe(0) + + log('Enable archived filter') + // Click on Tags filter button + await click(byId('table-filter-tags')) + + // Click on archived filter option + await clickByText(/^archived/, '.table-filter-item') + + // Close the filter dropdown + await click(byId('table-filter-tags')) + + log('Verify archived feature is now visible') + // Wait for the features list to update and verify archived feature appears + const archivedFeature = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("test_flag_with_tags")`) + }).first() + await archivedFeature.waitFor({ state: 'visible', timeout: 5000 }) + + }); +}); 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..1354f79c263a --- /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'; +import { E2E_SIGN_UP_USER, PASSWORD } from '../config'; + +test.describe('Signup', () => { + test('Create Organisation and Project @oss', async ({ page }) => { + const { addErrorLogging, click, logout, setText, waitForElementVisible } = createHelpers(page); + const flagsmith = await getFlagsmith(); + + // Add error logging + await addErrorLogging(); + + // Navigate to signup page + await page.goto('/'); + + log('Create Organisation'); + await click(byId('jsSignup')); + // Wait for firstName field to be visible after modal opens + await waitForElementVisible(byId('firstName')); + await setText(byId('firstName'), 'Bullet'); + await setText(byId('lastName'), 'Train'); + await setText(byId('email'), E2E_SIGN_UP_USER); + await setText(byId('password'), PASSWORD); + await click(byId('signup-btn')); + // Wait for navigation and form to load after signup + await page.waitForURL(/\/create/, { timeout: 20000 }); + await waitForElementVisible('[name="orgName"]'); + 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')); + + // Logout after test + await logout(); + }); +}); 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..01faf1596291 --- /dev/null +++ b/frontend/e2e/tests/invite-test.pw.ts @@ -0,0 +1,57 @@ +import { test } from '../test-setup'; +import { byId, log, createHelpers } from '../helpers'; +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('Users can be invited, sign up, change email, and delete their account @oss', async ({ page }) => { + const { + assertTextContent, + click, + getInputValue, + gotoAccountSettings, + login, + setText, + waitForElementNotExist, + waitForElementVisible, + } = createHelpers(page); + + 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 getInputValue(byId('organisation-name')) + await click(byId('users-and-permissions')) + const inviteLink = await getInputValue(byId('invite-link')) + log('Accept invite') + await page.goto(inviteLink) + // Wait for the form to load + await waitForElementVisible(byId('firstName')) + await setText(byId('firstName'), 'Bullet') + await setText(byId('lastName'), 'Train') + await setText(byId('email'), inviteEmail) + await setText(byId('password'), PASSWORD) + await waitForElementVisible(byId('signup-btn')) + // Wait for form validation to complete before clicking + await page.waitForTimeout(500) + await click(byId('signup-btn')) + log('Change email') + await gotoAccountSettings() + await click(byId('change-email-button')) + await setText("[name='EmailAddress']", E2E_CHANGE_MAIL) + await setText("[name='newPassword']", PASSWORD) + await click('#save-changes') + await waitForElementNotExist('.modal') + await login(E2E_CHANGE_MAIL, PASSWORD) + log('Delete invite user') + await assertTextContent('[id=account-settings-link]', 'Account') + await gotoAccountSettings() + await click(byId('delete-user-btn')) + await setText("[name='currentPassword']", PASSWORD) + await 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 a755d44ba12a..000000000000 --- a/frontend/e2e/tests/invite-test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - assertTextContent, - byId, - click, goToAccountSettings, - 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 goToAccountSettings() - 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 goToAccountSettings() - 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..b880f4e6ced8 --- /dev/null +++ b/frontend/e2e/tests/organisation-permission-test.pw.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../test-setup'; +import { byId, log, createHelpers } from '../helpers'; +import { + PASSWORD, + E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, + E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, +} from '../config'; + +test.describe('Organisation Permission Tests', () => { + test('Organisation-level permissions control project creation and group management @enterprise', async ({ page }) => { + const { + click, + clickByText, + login, + logout, + waitForElementClickable, + waitForElementNotClickable, + } = createHelpers(page); + + log('Login') + await 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(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-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..292f81ea950e --- /dev/null +++ b/frontend/e2e/tests/organisation-test.pw.ts @@ -0,0 +1,139 @@ +import { test, expect } from '../test-setup'; +import { byId, log, createHelpers } from '../helpers' +import { E2E_SEPARATE_TEST_USER, PASSWORD } from '../config' + +test.describe('Organisation Tests', () => { + test('Organisations can be created, renamed, and deleted with name validation @oss', async ({ page }) => { + const { + assertTextContent, + click, + clickByText, + closeModal, + getInputValue, + login, + setText, + waitForElementNotExist, + waitForElementVisible, + waitForModalToClose, + } = createHelpers(page); + + 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') + await waitForModalToClose() + + 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 waitForElementVisible("[name='confirm-org-name']") + 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') + // Wait for the organisation link to update to the new org before asserting + await assertTextContent('#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 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') + await waitForModalToClose() + + 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 waitForElementVisible("[name='confirm-org-name']") + 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 originalName = await getInputValue("[data-test='organisation-name']") + + log('Clear organisation name') + await 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 setText("[data-test='organisation-name']", originalName) + + log('Verify save button is enabled') + await expect(saveButton).not.toBeDisabled() + }); +}); 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..1b3bb6fbbd52 --- /dev/null +++ b/frontend/e2e/tests/project-permission-test.pw.ts @@ -0,0 +1,152 @@ +import { test, expect } from '../test-setup'; +import { + byId, + log, + createHelpers, +} from '../helpers'; +import { E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, E2E_USER, PASSWORD, E2E_PROJECT_WITH_PROJECT_PERMISSIONS } from '../config'; + +test.describe('Project Permission Tests', () => { + test('Project-level permissions control access to features, environments, audit logs, and segments @enterprise', async ({ page }) => { + const { + click, + clickFeatureAction, + createEnvironment, + createFeature, + getFeatureIndexByName, + gotoProject, + gotoSegments, + login, + logout, + setUserPermission, + toggleFeature, + waitForElementNotClickable, + waitForElementNotExist, + waitForElementVisible, + waitForFeatureSwitch, + waitForPageFullyLoaded, + } = createHelpers(page); + + const PROJECT_NAME = E2E_PROJECT_WITH_PROJECT_PERMISSIONS; + + 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 gotoProject(PROJECT_NAME) + 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 gotoProject(PROJECT_NAME) + 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', PROJECT_NAME, '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 gotoProject(PROJECT_NAME) + 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 gotoProject(PROJECT_NAME) + await createFeature({ name: 'test_feature', value: false }) + await toggleFeature('test_feature', true) + await logout() + log('Remove CREATE_FEATURE permissions') + await login(E2E_USER, PASSWORD) + await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'CREATE_FEATURE', PROJECT_NAME, 'project' ) + await logout() + log('User without CREATE_FEATURE cannot Handle the Features') + await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + 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', PROJECT_NAME, 'project' ) + await logout() + log('User with ADMIN permissions can set project settings') + await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + 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', PROJECT_NAME, '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', PROJECT_NAME, 'project' ) + await logout() + await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + 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 gotoProject(PROJECT_NAME) + await waitForPageFullyLoaded() + await waitForElementVisible('#features-page') + await waitForFeatureSwitch('test_feature', 'on') + await clickFeatureAction('test_feature') + const featureIndex = await getFeatureIndexByName('test_feature') + await waitForElementVisible(byId(`feature-remove-${featureIndex}`)) + await expect(page.locator(byId(`feature-remove-${featureIndex}`))).toHaveClass( + /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', PROJECT_NAME, 'project' ) + await logout() + log('User with permissions can Delete any feature') + await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await clickFeatureAction('test_feature') + const featureIndex2 = await getFeatureIndexByName('test_feature') + await waitForElementVisible(byId(`feature-remove-${featureIndex2}`)) + await expect(page.locator(byId(`feature-remove-${featureIndex2}`))).not.toHaveClass( + /feature-action__item_disabled/, + ) + await logout() + + log('User without MANAGE_SEGMENTS permissions cannot Manage Segments') + await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await gotoSegments() + const createSegmentBtn = page.locator(byId('show-create-segment-btn')) + await expect(createSegmentBtn).toBeDisabled() + 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', + PROJECT_NAME, + 'project' + ) + await logout() + log('User with MANAGE_SEGMENTS permissions can Manage Segments') + await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + await gotoProject(PROJECT_NAME) + await 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..fe33570f947a --- /dev/null +++ b/frontend/e2e/tests/project-test.pw.ts @@ -0,0 +1,79 @@ +import { test, expect } from '../test-setup'; +import { byId, getFlagsmith, log, createHelpers } from '../helpers'; +import { E2E_USER, PASSWORD } from '../config' + +test.describe('Project Tests', () => { + test('Additional Projects can be created and renamed with configurable change request approvals @enterprise', async ({ page }) => { + const { + assertInputValue, + assertTextContent, + click, + login, + setText, + waitForElementNotExist, + waitForElementVisible, + waitForToast, + } = createHelpers(page); + const flagsmith = await getFlagsmith() + const hasSegmentChangeRequests = flagsmith.hasFeature('segment_change_requests') + + log('Login') + await login(E2E_USER, PASSWORD) + + log('Create test project') + await click('.btn-project-create') + await waitForElementVisible(byId('projectName')) + await setText(byId('projectName'), 'Project Settings Test') + await click(byId('create-project-btn')) + await waitForElementVisible(byId('features-page')) + + log('Edit Project') + await click('#project-link') + await click('#project-settings-link') + await setText("[name='proj-name']", 'Project Settings Test Renamed') + await click('#save-proj-btn') + await assertTextContent(`#project-link`, 'Project Settings Test Renamed') + + 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') + await waitForToast() + 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') + } + + // Project will be cleaned up by E2E teardown + }); +}); 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..66e00a5f3976 --- /dev/null +++ b/frontend/e2e/tests/roles-test.pw.ts @@ -0,0 +1,64 @@ +import { test } from '../test-setup'; +import { byId, log, createHelpers } from '../helpers'; +import { + PASSWORD, + E2E_NON_ADMIN_USER_WITH_A_ROLE, + E2E_USER, + E2E_TEST_IDENTITY, +} from '../config' + +test.describe('Roles Tests', () => { + test('Roles can be created with project and environment permissions @enterprise', async ({ page }) => { + const { + click, + closeModal, + createFeature, + createRole, + deleteFeature, + gotoTraits, + login, + logout, + waitForElementVisible, + } = createHelpers(page); + + const rolesProject = 'project-my-test-project-7-role' + log('Login') + await login(E2E_USER, PASSWORD) + await click(byId(rolesProject)) + await createFeature({ name: 'test_feature', value: 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', [4]) + log('Add project permissions to the Role') + await click(byId(`role-0`)) + 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() + log('Login with the user with a new Role') + await page.evaluate(() => location.reload()); + await page.waitForTimeout(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(flagName) + + log('User with permissions can See the Identities') + await gotoTraits(E2E_TEST_IDENTITY) + }); +}); 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..0d78415c991c --- /dev/null +++ b/frontend/e2e/tests/segment-test.pw.ts @@ -0,0 +1,352 @@ +import { test, expect } from '../test-setup'; +import { byId, log, createHelpers } from '../helpers'; +import { E2E_USER, PASSWORD, E2E_TEST_IDENTITY, E2E_SEGMENT_PROJECT_1, E2E_SEGMENT_PROJECT_2, E2E_SEGMENT_PROJECT_3 } from '../config' + +const REMOTE_CONFIG_FEATURE = 'remote_config' +const FLAG_FEATURE = 'flag' + +// 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 { + addSegmentOverride, + assertInputValue, + assertUserFeatureValue, + click, + clickUserFeature, + cloneSegment, + closeModal, + createRemoteConfig, + createSegment, + createTrait, + deleteFeature, + deleteSegment, + deleteSegmentFromPage, + deleteTrait, + gotoFeature, + gotoFeatures, + gotoProject, + gotoSegments, + gotoTraits, + login, + navigateToSegment, + setSegmentRule, + waitAndRefresh, + waitForElementVisible, + waitForToast, + } = createHelpers(page) + + log('Login') + await login(E2E_USER, PASSWORD) + await gotoProject(E2E_SEGMENT_PROJECT_1) + await waitForElementVisible(byId('features-page')) + + log('Create Feature') + + await createRemoteConfig({ name: 'mv_flag', value: 'big', mvs: [ + { 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('segment_to_update', [lastRule]) + await navigateToSegment('segment_to_update') + await setSegmentRule(0, 0, lastRule.name, lastRule.operator, lastRule.value + 1) + await click(byId('update-segment')) + log('Check segment rule value') + await gotoSegments() + await navigateToSegment('segment_to_update') + await assertInputValue(byId(`rule-${0}-value-0`), `${lastRule.value + 1}`) + await deleteSegmentFromPage('segment_to_update') + + log('Create segment') + await createSegment('18_or_19', segmentRules) + + log('Add segment trait for user') + await gotoTraits(E2E_TEST_IDENTITY) + await createTrait('age', 18) + + // Wait for trait to be applied and feature values to load + await waitAndRefresh() + await assertUserFeatureValue('mv_flag', '"medium"') + await gotoFeatures() + await gotoFeature('mv_flag') + + await addSegmentOverride(0, true, 0, [ + { value: 'medium', weight: 0 }, + { value: 'small', weight: 100 }, + ]) + + await click('#update-feature-segments-btn') + await waitForToast() + await closeModal() + + await gotoTraits(E2E_TEST_IDENTITY) + await waitAndRefresh() + + await assertUserFeatureValue('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('age') + + log('Set user MV override') + await clickUserFeature('mv_flag') + await click(byId('select-variation-medium')) + await click(byId('update-feature-btn')) + await waitAndRefresh() + await assertUserFeatureValue('mv_flag', '"medium"') + + log('Clone segment') + await gotoSegments() + await cloneSegment('18_or_19', '0cloned-segment') + await deleteSegment('0cloned-segment') + + log('Delete segment') + await gotoSegments() + await deleteSegment('18_or_19') + await gotoFeatures() + await deleteFeature('mv_flag') +}) + +test('Segment test 2 - Test segment priority and overrides @oss', async ({ page }) => { + const { + addSegmentOverride, + addSegmentOverrideConfig, + assertUserFeatureValue, + createFeature, + createRemoteConfig, + createSegment, + createTrait, + deleteFeature, + gotoFeature, + gotoFeatures, + gotoProject, + gotoSegments, + goToUser, + login, + saveFeatureSegments, + setSegmentOverrideIndex, + waitForElementVisible, + waitForUserFeatureSwitch, + } = createHelpers(page) + + log('Login') + await login(E2E_USER, PASSWORD) + await gotoProject(E2E_SEGMENT_PROJECT_2) + await waitForElementVisible(byId('features-page')) + + log('Create segments') + await gotoSegments() + await createSegment('segment_1', [ + { + name: 'trait', + operator: 'EQUAL', + value: '1', + }, + ]) + await createSegment('segment_2', [ + { + name: 'trait2', + operator: 'EQUAL', + value: '2', + }, + ]) + await createSegment('segment_3', [ + { + name: 'trait3', + operator: 'EQUAL', + value: '3', + }, + ]) + + log('Create Features') + await gotoFeatures() + await createFeature({ name: 'flag' }) + await createRemoteConfig({ name: 'config', value: 0 }) + + log('Set segment overrides features') + await gotoFeature('config') + await addSegmentOverrideConfig(0, 1, 0) + await addSegmentOverrideConfig(1, 2, 0) + await addSegmentOverrideConfig(2, 3, 0) + await saveFeatureSegments() + await gotoFeature('flag') + 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(E2E_TEST_IDENTITY) + await createTrait('trait', 1) + await createTrait('trait2', 2) + await createTrait('trait3', 3) + await waitForUserFeatureSwitch('flag', 'on') + // Wait for feature values to update after trait creation + await page.waitForTimeout(1000) + await assertUserFeatureValue('config', '1') + + log('Prioritise segment 2') + await gotoFeatures() + await gotoFeature('config') + await setSegmentOverrideIndex(1, 0) + await saveFeatureSegments() + await gotoFeature('flag') + await setSegmentOverrideIndex(1, 0) + await saveFeatureSegments() + await goToUser(E2E_TEST_IDENTITY) + await waitForUserFeatureSwitch('flag', 'off') + await assertUserFeatureValue('config', '2') + + log('Prioritise segment 3') + await gotoFeatures() + await gotoFeature('config') + await setSegmentOverrideIndex(2, 0) + await saveFeatureSegments() + await gotoFeature('flag') + await setSegmentOverrideIndex(2, 0) + await saveFeatureSegments() + await goToUser(E2E_TEST_IDENTITY) + await waitForUserFeatureSwitch('flag', 'on') + await assertUserFeatureValue('config', '3') + + log('Clear down features') + await gotoFeatures() + await deleteFeature('flag') + await deleteFeature('config') +}) + +test('Segment test 3 - Test user-specific feature overrides @oss', async ({ page }) => { + const { + assertUserFeatureValue, + click, + clickUserFeature, + clickUserFeatureSwitch, + closeModal, + createFeature, + createRemoteConfig, + deleteFeature, + gotoFeature, + gotoFeatures, + gotoProject, + goToUser, + login, + setText, + waitAndRefresh, + waitForElementVisible, + waitForUserFeatureSwitch, + } = createHelpers(page) + + log('Login') + await login(E2E_USER, PASSWORD) + await gotoProject(E2E_SEGMENT_PROJECT_3) + await waitForElementVisible(byId('features-page')) + + log('Create features') + await gotoFeatures() + await createFeature({ name: FLAG_FEATURE, value: true }) + await createRemoteConfig({ name: REMOTE_CONFIG_FEATURE, value: 0, description: 'Description' }) + + log('Toggle flag for user') + await goToUser(E2E_TEST_IDENTITY) + await clickUserFeatureSwitch(FLAG_FEATURE, '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 waitForUserFeatureSwitch(FLAG_FEATURE, 'off') + + log('Edit flag for user') + await clickUserFeature(REMOTE_CONFIG_FEATURE) + 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 assertUserFeatureValue(REMOTE_CONFIG_FEATURE, '"small"') + + log('Verify identity override appears in feature modal') + await gotoFeatures() + await gotoFeature(REMOTE_CONFIG_FEATURE) + await click('[data-test="identity_overrides"]') + await page.waitForTimeout(1000) // Wait for identity overrides to load + + // Check that the test identity appears in the list + const identityRow = page.locator('[id="users-list"]').locator('.list-item').filter({ + hasText: E2E_TEST_IDENTITY + }) + await expect(identityRow).toBeVisible() + + // Check that the override value is displayed correctly + const valueInList = identityRow.locator('.table-column').filter({ hasText: 'small' }) + await expect(valueInList).toBeVisible() + + log('Close modal') + await closeModal() + + log('Toggle flag for user again') + await goToUser(E2E_TEST_IDENTITY) + await clickUserFeatureSwitch(FLAG_FEATURE, '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 waitForUserFeatureSwitch(FLAG_FEATURE, 'on'); + + log('Clear down features') + await gotoFeatures() + await deleteFeature(FLAG_FEATURE) + await deleteFeature(REMOTE_CONFIG_FEATURE) +}) diff --git a/frontend/e2e/tests/segment-test.ts b/frontend/e2e/tests/segment-test.ts deleted file mode 100644 index 15e2916afc79..000000000000 --- a/frontend/e2e/tests/segment-test.ts +++ /dev/null @@ -1,276 +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 - // After toggling, 'flag' has an identity override and sorts first (index 0). - await waitForElementVisible(byId('user-feature-switch-0-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-0-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-0-on')) -} diff --git a/frontend/e2e/tests/versioning-tests.ts b/frontend/e2e/tests/versioning-tests.pw.ts similarity index 50% rename from frontend/e2e/tests/versioning-tests.ts rename to frontend/e2e/tests/versioning-tests.pw.ts index 03ce7f8cf14f..7f8ee499c900 100644 --- a/frontend/e2e/tests/versioning-tests.ts +++ b/frontend/e2e/tests/versioning-tests.pw.ts @@ -1,34 +1,36 @@ +import { test, expect } from '../test-setup'; import { - assertNumberOfVersions, byId, - checkApiRequest, - click, - compareVersion, - createFeature, - createOrganisationAndProject, - createRemoteConfig, - editRemoteConfig, getFlagsmith, log, - login, - parseTryItResults, - toggleFeature, - waitForElementVisible, -} from '../helpers.cafe'; -import { t } from 'testcafe'; + createHelpers, +} from '../helpers'; 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 () => { +test('Versioning tests - Create, edit, and compare feature versions @oss', async ({ page }) => { + const { + assertNumberOfVersions, + click, + compareVersion, + createFeature, + createOrganisationAndProject, + createRemoteConfig, + editRemoteConfig, + login, + parseTryItResults, + toggleFeature, + waitForElementVisible, + waitForFeatureSwitch, + } = createHelpers(page) 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.") + test.skip() return } @@ -37,102 +39,94 @@ export default async () => { await click('#env-settings-link') await click(byId('enable-versioning')) await click('#confirm-btn-yes') + // Feature versioning takes up to a minute to enable on the backend await waitForElementVisible(byId('feature-versioning-enabled')) log('Create feature 1') - await createRemoteConfig(0, 'a', 'small') + await createRemoteConfig({ name: 'a', value: 'small' }) log('Edit feature 1') - await editRemoteConfig(0,'medium') + await editRemoteConfig('a', 'medium') log('Create feature 2') - await createRemoteConfig(1, 'b', 'small', null, null, [ + await createRemoteConfig({ name: 'b', value: 'small', mvs: [ { value: 'medium', weight: 100 }, { value: 'big', weight: 0 }, - ]) + ]}) log('Edit feature 2') - await editRemoteConfig(1,'small',false,[ + await editRemoteConfig('b', 'small', false, [ { value: 'medium', weight: 0 }, { value: 'big', weight: 100 }, ]) log('Create feature 3') - await createFeature(2, 'c', false) + await createFeature({ name: 'c', value: false }) log('Edit feature 3') - await editRemoteConfig(2,'',true) + await editRemoteConfig('c', '', 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) + await assertNumberOfVersions('a', 2) + await assertNumberOfVersions('b', 2) + await assertNumberOfVersions('c', 2) + await compareVersion('a', 0, null, true, true, 'small', 'medium') + await compareVersion('b', 0, null, true, true, 'small', 'small') + await compareVersion('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(2,'',true) above. + // Note: Feature 'c' is currently ON after editRemoteConfig('c', '', 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 + // Feature 'c' 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') + await toggleFeature('c', false) // Verify: Switch shows OFF state on features list - await waitForElementVisible(byId('feature-switch-2-off')) + await waitForFeatureSwitch('c', '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) + expect(json.c.enabled).toBe(false) // Refresh page to verify state was persisted to backend log('Refresh page to verify toggle OFF persisted') - await t.eval(() => location.reload()) + await page.reload() await waitForElementVisible(byId('features-page')) - await waitForElementVisible(byId('feature-switch-2-off')) - - // Clear logger before second toggle - versionApiLogger.clear() + await waitForFeatureSwitch('c', 'off') // 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') + await toggleFeature('c', true) // Verify: Switch shows ON state on features list - await waitForElementVisible(byId('feature-switch-2-on')) + await waitForFeatureSwitch('c', 'on') // Verify: API returns correct state (feature enabled) log('Verify API returns enabled state') - await t.wait(500) + // 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 click('#try-it-btn') - await t.wait(500) + await responsePromise + json = await parseTryItResults() - await t.expect(json.c.enabled).eql(true) + expect(json.c.enabled).toBe(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')) + await page.reload() + await waitForElementVisible(byId('features-page')); + await waitForFeatureSwitch('c', 'on'); - log('Versioned toggle test passed') -} + log('Versioned toggle test passed'); +}) diff --git a/frontend/global.d.ts b/frontend/global.d.ts index 192be66a514e..7dbeb7d97431 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -93,6 +93,7 @@ declare global { const PanelSearch: typeof Component const CodeHelp: typeof Component interface Window { + E2E: boolean $crisp: Crisp engagement: { init(apiKey: string, options?: InitOptions): void diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1354f915dec4..7ec08b2768db 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.105.0", @@ -125,8 +124,9 @@ }, "devDependencies": { "@dword-design/eslint-plugin-import-alias": "^2.0.7", - "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@playwright/test": "^1.58.2", "@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 +139,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 +155,11 @@ "lint-staged": "^12.3.4", "minimist": "^1.2.8", "nodemon": "^3.0.1", + "playwright": "^1.58.2", "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 +168,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", @@ -979,23 +974,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 +1041,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 +1075,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 +1524,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 +2015,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 +2266,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", @@ -2628,83 +2446,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 +2505,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", @@ -3085,148 +2779,13 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@ffmpeg-installer/darwin-arm64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz", - "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==", - "cpu": [ - "arm64" - ], - "dev": true, - "hasInstallScript": true, - "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@ffmpeg-installer/darwin-x64": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz", - "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==", - "cpu": [ - "x64" - ], - "dev": true, - "hasInstallScript": true, - "license": "LGPL-2.1", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@ffmpeg-installer/ffmpeg": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz", - "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==", - "dev": true, - "license": "LGPL-2.1", - "optionalDependencies": { - "@ffmpeg-installer/darwin-arm64": "4.1.5", - "@ffmpeg-installer/darwin-x64": "4.1.0", - "@ffmpeg-installer/linux-arm": "4.1.3", - "@ffmpeg-installer/linux-arm64": "4.1.4", - "@ffmpeg-installer/linux-ia32": "4.1.0", - "@ffmpeg-installer/linux-x64": "4.1.0", - "@ffmpeg-installer/win32-ia32": "4.1.0", - "@ffmpeg-installer/win32-x64": "4.1.0" - } - }, - "node_modules/@ffmpeg-installer/linux-arm": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz", - "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==", - "cpu": [ - "arm" - ], - "dev": true, - "hasInstallScript": true, - "license": "GPLv3", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@ffmpeg-installer/linux-arm64": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz", - "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "hasInstallScript": true, - "license": "GPLv3", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@ffmpeg-installer/linux-ia32": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz", - "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "hasInstallScript": true, - "license": "GPLv3", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@ffmpeg-installer/linux-x64": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz", - "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==", - "cpu": [ - "x64" - ], - "dev": true, - "hasInstallScript": true, - "license": "GPLv3", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@ffmpeg-installer/win32-ia32": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz", - "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "GPLv3", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@ffmpeg-installer/win32-x64": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz", - "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "GPLv3", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { @@ -4980,6 +4539,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "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", @@ -5706,6 +5281,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", @@ -5913,16 +5498,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", @@ -6026,12 +5601,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", @@ -6041,12 +5610,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", @@ -6210,6 +5773,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", @@ -6911,6 +6484,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", @@ -6936,21 +6522,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-phases": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", @@ -6985,15 +6556,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", @@ -7010,6 +6572,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", @@ -7093,6 +6656,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" @@ -7108,6 +6672,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" @@ -7154,6 +6719,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" @@ -7169,6 +6735,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" @@ -7181,6 +6748,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": { @@ -7215,142 +6783,333 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "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": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" + "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" }, - "funding": { - "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", - "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", - "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 14" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "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": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "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" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 14" } }, - "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==", + "node_modules/archiver-utils/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": "18 || 20 || >=22" } }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "18 || 20 || >=22" } }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "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": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", + "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==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "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.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "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", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", @@ -7373,15 +7132,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", @@ -7396,17 +7146,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", @@ -7662,6 +7404,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": { @@ -7901,12 +7644,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", @@ -7982,6 +7719,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", @@ -8151,12 +7903,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", @@ -8260,6 +8006,16 @@ "ieee754": "^1.1.13" } }, + "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", @@ -8323,14 +8079,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", @@ -8394,23 +8142,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", @@ -8475,18 +8206,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", @@ -8523,25 +8242,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", @@ -8551,12 +8251,6 @@ "node": ">=6.0" } }, - "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" - }, "node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", @@ -8595,6 +8289,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" @@ -8798,19 +8493,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", @@ -8905,6 +8587,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", @@ -9024,6 +8778,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": { @@ -9064,6 +8819,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", @@ -9179,16 +9003,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", @@ -9569,6 +9383,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" @@ -9586,18 +9401,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", @@ -9664,99 +9467,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", @@ -9797,16 +9507,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", @@ -9827,18 +9527,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.2", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", @@ -10072,32 +9760,6 @@ "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "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==", - "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_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -10133,15 +9795,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.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -10189,6 +9842,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" @@ -10859,21 +10513,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", @@ -10964,6 +10603,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", @@ -10979,6 +10628,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", @@ -11266,8 +10925,15 @@ "node": ">=6.0.0" } }, - "node_modules/fast-glob": { - "version": "3.3.3", + "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", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "license": "MIT", @@ -11964,15 +11630,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", @@ -11997,18 +11654,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", @@ -12032,15 +11677,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", @@ -12071,15 +11707,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", @@ -12218,15 +11845,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", @@ -12421,79 +12039,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", @@ -12725,12 +12270,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", @@ -12836,45 +12375,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", @@ -12897,12 +12397,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", @@ -13050,15 +12544,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", @@ -13186,6 +12671,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" @@ -13503,18 +12989,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", @@ -13579,6 +13053,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" @@ -13596,12 +13071,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", @@ -13627,18 +13096,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", @@ -13780,43 +13237,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" @@ -13846,18 +13271,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", @@ -13989,12 +13402,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", @@ -15245,12 +14652,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", @@ -15473,15 +14874,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", @@ -15491,6 +14883,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", @@ -15605,15 +15050,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", @@ -15860,39 +15296,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", @@ -15988,22 +15391,10 @@ "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", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" @@ -16047,24 +15438,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", @@ -16777,12 +16150,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.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", @@ -16840,12 +16207,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", @@ -16870,18 +16231,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", @@ -17353,12 +16702,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", @@ -17554,11 +16897,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", @@ -17607,12 +16945,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", @@ -17674,15 +17006,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", @@ -17729,27 +17052,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", @@ -17887,6 +17189,53 @@ "node": ">=6" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "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", @@ -17897,15 +17246,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", @@ -18156,15 +17496,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", @@ -18174,10 +17505,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": { @@ -18204,28 +17546,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", @@ -18265,18 +17585,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", @@ -18284,16 +17592,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", @@ -18326,14 +17624,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", @@ -18363,12 +17653,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", @@ -19251,15 +18535,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", @@ -19275,6 +18550,39 @@ "node": ">= 6" } }, + "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": { + "minimatch": "^5.1.0" + } + }, + "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.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", + "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", @@ -19626,27 +18934,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", @@ -19714,27 +19001,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", @@ -19959,15 +19225,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", @@ -20172,12 +19429,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", @@ -20443,12 +19694,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", @@ -20589,6 +19834,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": { @@ -20614,6 +19860,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", @@ -21012,6 +20270,33 @@ "url": "https://opencollective.com/webpack" } }, + "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.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "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", @@ -21042,1123 +20327,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.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "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.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.7.tgz", - "integrity": "sha512-FjiwU9HaHW6YB3H4a1sFudnv93lvydNjz2lmyUXR6IwKhGI+bgL3SOZrBGn6kvvX2pJvhEkGSGjyTHN47O4rqA==", - "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.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "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.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "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", @@ -22173,15 +20463,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", @@ -22254,36 +20535,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", @@ -22304,15 +20561,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", @@ -22503,6 +20751,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" @@ -22524,15 +20773,6 @@ "node": ">= 0.8.0" } }, - "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==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -22715,12 +20955,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", @@ -22876,15 +21110,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", @@ -22906,12 +21131,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", @@ -22986,25 +21205,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", @@ -23014,12 +21214,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", @@ -23539,59 +21733,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", @@ -23665,21 +21806,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", @@ -23700,6 +21826,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", @@ -23768,12 +21895,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" @@ -23783,6 +21912,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", @@ -23977,6 +22107,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 2e9c627337f8..6d4a92679aed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,19 +5,19 @@ "main": "index.js", "scripts": { "kill": "kill -9 $(lsof -ti tcp:4444)", - "test:bundle": "cross-env E2E=1 npm run bundle", "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: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": "npx -y tsx e2e/run-with-retry.ts", + "test:run": "cross-env npm run bundle && npx playwright test", + "test:dev": "cross-env E2E_SHOW_BROWSER=true npx playwright test --ui", + "test:devlocal": "cross-env E2E_SHOW_BROWSER=true E2E_SKIP_RUN_FRONTEND=true npx playwright test --ui", + "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", + "test:report": "npx playwright show-report e2e/playwright-report", "env": "node ./bin/env.js", "lint": "eslint .", "lint:fix": "npx eslint --fix .", @@ -139,7 +139,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.105.0", @@ -153,8 +152,9 @@ }, "devDependencies": { "@dword-design/eslint-plugin-import-alias": "^2.0.7", - "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@playwright/test": "^1.58.2", "@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 +167,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 +183,11 @@ "lint-staged": "^12.3.4", "minimist": "^1.2.8", "nodemon": "^3.0.1", + "playwright": "^1.58.2", "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..eeeaf1387fe0 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,107 @@ +import { defineConfig, devices } from '@playwright/test' +import Project from './common/project' + +/** + * 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'), + /* 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_SHOW_BROWSER, + + // 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 + ['./e2e/failed-tests-reporter.ts'], // Writes failed.json for CI + ], + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + testDir: './e2e', + testMatch: /.*\.pw\.ts$/, + /* Test timeout */ + timeout: 300000, + /* 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: { + mode: 'on', + screenshots: true, + snapshots: true, + sources: true, + }, + /* Video disabled - view artifacts in GitHub Actions */ + video: 'off', + }, + /* Run your local dev server before starting the tests */ + webServer: process.env.E2E_SKIP_RUN_FRONTEND + ? undefined + : { + command: `cross-env E2E=true ENV=${Project.env} 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, +}) diff --git a/frontend/web/components/TryIt.js b/frontend/web/components/TryIt.js index cc7e4fc59cfc..c14f13e0184e 100644 --- a/frontend/web/components/TryIt.js +++ b/frontend/web/components/TryIt.js @@ -25,14 +25,6 @@ const TryIt = class extends Component { headers: { 'X-Environment-Key': environmentId }, } - if (E2E && document.getElementById('e2e-request')) { - const payload = { - options, - url, - } - document.getElementById('e2e-request').innerText = JSON.stringify(payload) - } - fetch(url, options) .then((res) => res.json()) .then((data) => { diff --git a/frontend/web/components/import-export/ImportPage.tsx b/frontend/web/components/import-export/ImportPage.tsx index 82d90c0e3452..b3df01128ec1 100644 --- a/frontend/web/components/import-export/ImportPage.tsx +++ b/frontend/web/components/import-export/ImportPage.tsx @@ -52,9 +52,13 @@ const ImportPage: FC = ({ projectId, projectName }) => { if (status?.status?.result === 'success') { const count = status.status.requested_flag_count const deprecated = status.status.deprecated_flag_count ?? 0 - let message = `Imported ${count} flag${count !== 1 ? 's' : ''} from LaunchDarkly.` + let message = `Imported ${count} flag${ + count !== 1 ? 's' : '' + } from LaunchDarkly.` if (deprecated > 0) { - message += ` ${deprecated} deprecated flag${deprecated !== 1 ? 's were' : ' was'} archived.` + message += ` ${deprecated} deprecated flag${ + deprecated !== 1 ? 's were' : ' was' + } archived.` } toast(message, 'success', 20000) history.push(`/project/${projectId}`) diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index 3022a9ff92c9..a00465d4bb33 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -347,7 +347,9 @@ const CreateSegment: FC = ({ useEffect(() => { setTimeout(() => { - document.getElementById('segmentID')?.focus() + if (!E2E) { + document.getElementById('segmentID')?.focus() + } }, 500) }, []) useEffect(() => { @@ -673,7 +675,10 @@ const LoadingCreateSegment: FC = (props) => { { id: `${props.projectId}` }, { skip: !props.projectId }, ) - const isLoading = projectLoading || segmentLoading + const { isLoading: contentTypesLoading } = useGetSupportedContentTypeQuery({ + organisation_id: AccountStore.getOrganisation().id, + }) + const isLoading = projectLoading || segmentLoading || contentTypesLoading const [page, setPage] = useState({ number: 1, pageType: undefined, diff --git a/frontend/web/components/navigation/AccountDropdown.tsx b/frontend/web/components/navigation/AccountDropdown.tsx index 1dd7c8b025ce..851702555154 100644 --- a/frontend/web/components/navigation/AccountDropdown.tsx +++ b/frontend/web/components/navigation/AccountDropdown.tsx @@ -16,7 +16,7 @@ const AccountDropdown: React.FC = () => { const btnRef = useRef(null) const dropDownRef = useRef(null) const history = useHistory() - + useOutsideClick(dropDownRef, () => setIsOpen(false)) useLayoutEffect(() => { diff --git a/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx b/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx index 4d9c9271354c..2e84884283cf 100644 --- a/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx +++ b/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx @@ -88,7 +88,7 @@ const EnvironmentNavbar: FC = ({ )} diff --git a/frontend/web/components/pages/ChangeRequestDetailPage.tsx b/frontend/web/components/pages/ChangeRequestDetailPage.tsx index 69510ced5971..90a90f76075c 100644 --- a/frontend/web/components/pages/ChangeRequestDetailPage.tsx +++ b/frontend/web/components/pages/ChangeRequestDetailPage.tsx @@ -700,6 +700,7 @@ export const ChangeRequestPageInner: FC = ({ 'Approve Change Requests', ),