diff --git a/.dockerignore b/.dockerignore index e05914176..28c6753f5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,11 +19,7 @@ Dockerfile .git/ORIG_HEAD .git/packed-refs .git/refs/remotes/ -.git/rr-cache/ .gitignore settings.json src/node_modules -admin/node_modules -ui/node_modules -node_modules diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index f521471dc..000000000 --- a/.editorconfig +++ /dev/null @@ -1,17 +0,0 @@ -root = true - -[*] -indent_style = space -indent_size = 2 -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true -end_of_line = lf -# editorconfig-tools is unable to ignore longs strings or urls -max_line_length = off - -[CHANGELOG.md] -indent_size = 4 - -[*.bat] -end_of_line = crlf diff --git a/.env.default b/.env.default deleted file mode 100644 index e9b560b72..000000000 --- a/.env.default +++ /dev/null @@ -1,18 +0,0 @@ -# Please copy and rename this file. -# -# !Attention! -# Always ensure to load the env variables in every terminal session. -# Otherwise the env variables will not be available - -DOCKER_COMPOSE_APP_PORT_PUBLISHED=9001 -DOCKER_COMPOSE_APP_PORT_TARGET=9001 - -# IMPORTANT: When the env var DEFAULT_PAD_TEXT is unset or empty, then the pad is not established (not the landing page). -# The env var DEFAULT_PAD_TEXT seems to be mandatory in the latest version of etherpad. -DOCKER_COMPOSE_APP_DEV_ENV_DEFAULT_PAD_TEXT="Welcome to etherpad" - -DOCKER_COMPOSE_APP_ADMIN_PASSWORD= - -DOCKER_COMPOSE_POSTGRES_DATABASE=db -DOCKER_COMPOSE_POSTGRES_PASSWORD=etherpad-lite-password -DOCKER_COMPOSE_POSTGRES_USER=etherpad-lite-user diff --git a/.env.dev.default b/.env.dev.default deleted file mode 100644 index b78b5599a..000000000 --- a/.env.dev.default +++ /dev/null @@ -1,18 +0,0 @@ -# Please copy and rename this file. -# -# !Attention! -# Always ensure to load the env variables in every terminal session. -# Otherwise the env variables will not be available - -DOCKER_COMPOSE_APP_DEV_PORT_PUBLISHED=9001 -DOCKER_COMPOSE_APP_DEV_PORT_TARGET=9001 - -# IMPORTANT: When the env var DEFAULT_PAD_TEXT is unset or empty, then the pad is not established (not the landing page). -# The env var DEFAULT_PAD_TEXT seems to be mandatory in the latest version of etherpad. -DOCKER_COMPOSE_APP_DEV_ENV_DEFAULT_PAD_TEXT="Welcome to etherpad" - -DOCKER_COMPOSE_APP_DEV_ADMIN_PASSWORD= - -DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_DATABASE=db -DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_PASSWORD=etherpad-lite-password -DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_USER=etherpad-lite-user \ No newline at end of file diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 6313b56c5..000000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 041f7c1a9..dd84ea782 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,8 +7,6 @@ assignees: '' --- - - **Describe the bug** A clear and concise description of what the bug is. @@ -25,13 +23,6 @@ A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. -**Server (please complete the following information):** - - Etherpad version: - - OS: [e.g., Ubuntu 20.04] - - Node.js version (`node --version`): - - npm version (`npm --version`): - - Is the server free of plugins: - **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d9f660907..d745034b4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,13 +1,29 @@ + +> Please provide enough information so that others can review your pull request: + + + +> Explain the **details** for making this change. What existing problem does the pull request solve? + + + +> Screenshots/GIFs + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 1e7ac79ed..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: 2 -updates: - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "daily" - versioning-strategy: "increase" - open-pull-requests-limit: 30 - groups: - dev-dependencies: - dependency-type: "development" \ No newline at end of file diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 000000000..2e530c6c0 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,23 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - Bug + - Serious Bug + - Minor bug + - Black hole bug + - Special case Bug + - Upstream bug +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 3c6e63bb4..a9c8c602e 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -1,290 +1,87 @@ name: "Backend tests" # any branch is useful for testing before a PR is submitted -on: - push: - paths-ignore: - - "doc/**" - pull_request: - paths-ignore: - - "doc/**" - -permissions: - contents: read +on: [push, pull_request] jobs: - withoutpluginsLinux: + withoutplugins: # run on pushes to any branch # run on PRs from external forks if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) - name: Linux without plugins + (github.event_name != 'pull_request') + || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) + name: without plugins runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [20, 22, 23] - steps: - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node }} - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.5.0 - with: - packages: libreoffice libreoffice-pdfimport - version: 1.0 - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installDeps.sh - - name: Install admin ui - working-directory: admin - run: pnpm install - - name: Build admin ui - working-directory: admin - run: pnpm build - - - name: Run the backend tests - run: pnpm test - - name: Run the new vitest tests - working-directory: src - run: pnpm run test:vitest - withpluginsLinux: + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - name: Install libreoffice + run: | + sudo add-apt-repository -y ppa:libreoffice/ppa + sudo apt update + sudo apt install -y --no-install-recommends libreoffice libreoffice-pdfimport + + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + - name: Run the backend tests + run: cd src && npm test + + withplugins: # run on pushes to any branch # run on PRs from external forks if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) - name: Linux with Plugins + (github.event_name != 'pull_request') + || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) + name: with Plugins runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [20, 22, 23] - steps: - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node }} - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.5.0 - with: - packages: libreoffice libreoffice-pdfimport - version: 1.0 - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installDeps.sh - - name: Install admin ui - working-directory: admin - run: pnpm install - - name: Build admin ui - working-directory: admin - run: pnpm build - - - name: Install Etherpad plugins - run: > - pnpm install --workspace-root - ep_align - ep_author_hover - ep_cursortrace - ep_font_size - ep_hash_auth - ep_headings2 - ep_markdown - ep_readonly_guest - ep_set_title_on_pad - ep_spellcheck - ep_subscript_and_superscript - ep_table_of_contents - - - name: Run the backend tests - run: pnpm test - - name: Run the new vitest tests - working-directory: src - run: pnpm run test:vitest - - withoutpluginsWindows: - # run on pushes to any branch - # run on PRs from external forks - if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) - name: Windows without plugins - runs-on: windows-latest - steps: - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installOnWindows.bat - - name: Install admin ui - working-directory: admin - run: pnpm install - - name: Build admin ui - working-directory: admin - run: pnpm build - - - name: Fix up the settings.json - run: | - powershell -Command "(gc settings.json.template) -replace '\"max\": 10', '\"max\": 10000' | Out-File -encoding ASCII settings.json.holder" - powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json" - - - name: Run the backend tests - working-directory: src - run: pnpm test - - name: Run the new vitest tests - working-directory: src - run: pnpm run test:vitest - - withpluginsWindows: - # run on pushes to any branch - # run on PRs from external forks - if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) - name: Windows with Plugins - runs-on: windows-latest steps: - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - name: Install admin ui - working-directory: admin - run: pnpm install - - name: Build admin ui - working-directory: admin - run: pnpm build - - - name: Install Etherpad plugins - # The --legacy-peer-deps flag is required to work around a bug in npm - # v7: https://github.com/npm/cli/issues/2199 - run: > - pnpm install --workspace-root - ep_align - ep_author_hover - ep_cursortrace - ep_font_size - ep_hash_auth - ep_headings2 - ep_markdown - ep_readonly_guest - ep_set_title_on_pad - ep_spellcheck - ep_subscript_and_superscript - ep_table_of_contents - # Etherpad core dependencies must be installed after installing the - # plugin's dependencies, otherwise npm will try to hoist common - # dependencies by removing them from src/node_modules and installing them - # in the top-level node_modules. As of v6.14.10, npm's hoist logic appears - # to be buggy, because it sometimes removes dependencies from - # src/node_modules but fails to add them to the top-level node_modules. - # Even if npm correctly hoists the dependencies, the hoisting seems to - # confuse tools such as `npm outdated`, `npm update`, and some ESLint - # rules. - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installOnWindows.bat - - - name: Fix up the settings.json - run: | - powershell -Command "(gc settings.json.template) -replace '\"max\": 10', '\"max\": 10000' | Out-File -encoding ASCII settings.json.holder" - powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json" - - - name: Run the backend tests - working-directory: src - run: pnpm test - - name: Run the new vitest tests - working-directory: src - run: pnpm run test:vitest + - name: Checkout repository + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - name: Install libreoffice + run: | + sudo add-apt-repository -y ppa:libreoffice/ppa + sudo apt update + sudo apt install -y --no-install-recommends libreoffice libreoffice-pdfimport + + - name: Install Etherpad plugins + run: > + npm install + ep_align + ep_author_hover + ep_cursortrace + ep_font_size + ep_hash_auth + ep_headings2 + ep_image_upload + ep_markdown + ep_readonly_guest + ep_set_title_on_pad + ep_spellcheck + ep_subscript_and_superscript + ep_table_of_contents + + # This must be run after installing the plugins, otherwise npm will try to + # hoist common dependencies by removing them from src/node_modules and + # installing them in the top-level node_modules. As of v6.14.10, npm's hoist + # logic appears to be buggy, because it sometimes removes dependencies from + # src/node_modules but fails to add them to the top-level node_modules. Even + # if npm correctly hoists the dependencies, the hoisting seems to confuse + # tools such as `npm outdated`, `npm update`, and some ESLint rules. + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + - name: Run the backend tests + run: cd src && npm test diff --git a/.github/workflows/build-and-deploy-docs.yml b/.github/workflows/build-and-deploy-docs.yml deleted file mode 100644 index 3134de22a..000000000 --- a/.github/workflows/build-and-deploy-docs.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Workflow for deploying static content to GitHub Pages -name: Deploy Docs to GitHub Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: ["develop"] - paths: - - doc/** # Only run workflow when changes are made to the doc directory - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - packages: read - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Pages - uses: actions/configure-pages@v5 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - name: Install dependencies - run: pnpm install - - name: Build app - working-directory: doc - run: pnpm run docs:build - env: - COMMIT_REF: ${{ github.sha }} - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - # Upload entire repository - path: './doc/.vitepress/dist' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 94dd1ac75..f3b1cf2c2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -6,41 +6,49 @@ on: pull_request: # The branches below must be a subset of the branches above branches: [develop] - paths-ignore: - - 'doc/**' schedule: - cron: '0 13 * * 1' -permissions: - contents: read - jobs: analyze: - permissions: - actions: read # for github/codeql-action/init to get workflow details - contents: read # for actions/checkout to fetch code - security-events: write # for github/codeql-action/autobuild to send a status report name: Analyze runs-on: ubuntu-latest + steps: - - - name: Checkout repository - uses: actions/checkout@v4 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + # Override language selection by uncommenting this and choosing your languages + # with: + # languages: go, javascript, csharp, python, cpp, java + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 9a9dcfebb..000000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Dependency Review Action -# -# This Action will scan dependency manifest files that change as part of a Pull Reqest, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. -# -# Source repository: https://github.com/actions/dependency-review-action -# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement -name: 'Dependency Review' -on: [pull_request] - -permissions: - contents: read - -jobs: - dependency-review: - runs-on: ubuntu-latest - steps: - - name: 'Checkout Repository' - uses: actions/checkout@v4 - - name: 'Dependency Review' - uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 313d48fc6..000000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Docker -on: - pull_request: - paths-ignore: - - 'doc/**' - push: - branches: - - 'develop' - paths-ignore: - - 'doc/**' - tags: - - 'v?[0-9]+.[0-9]+.[0-9]+' -env: - TEST_TAG: etherpad/etherpad:test -permissions: - contents: read - -jobs: - docker: - runs-on: ubuntu-latest - steps: - - - name: Check out - uses: actions/checkout@v4 - with: - path: etherpad - - - - name: Set up QEMU - if: github.event_name == 'push' - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and export to Docker - uses: docker/build-push-action@v6 - with: - context: ./etherpad - target: production - load: true - tags: ${{ env.TEST_TAG }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 'lts/*' - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Test - working-directory: etherpad - run: | - docker run --rm -d -p 9001:9001 --name test ${{ env.TEST_TAG }} - ./bin/installDeps.sh - docker logs -f test & - while true; do - echo "Waiting for Docker container to start..." - status=$(docker container inspect -f '{{.State.Health.Status}}' test) || exit 1 - case ${status} in - healthy) break;; - starting) sleep 2;; - *) printf %s\\n "unexpected status: ${status}" >&2; exit 1;; - esac - done - (cd src && pnpm run test-container) - git clean -dxf . - - - name: Docker meta - if: github.event_name == 'push' - id: meta - uses: docker/metadata-action@v5 - with: - images: etherpad/etherpad - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - - - name: Log in to Docker Hub - if: github.event_name == 'push' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push - id: build-docker - if: github.event_name == 'push' - uses: docker/build-push-action@v6 - with: - context: ./etherpad - target: production - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - name: Update repo description - uses: peter-evans/dockerhub-description@v4 - if: github.ref == 'refs/heads/master' - with: - readme-filepath: ./etherpad/README.md - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: etherpad/etherpad - enable-url-completion: true - - name: Check out - if: github.event_name == 'push' && github.ref == 'refs/heads/develop' - uses: actions/checkout@v4 - with: - path: ether-charts - repository: ether/ether-charts - token: ${{ secrets.ETHER_CHART_TOKEN }} - - name: Update tag in values-dev.yaml - if: success() && github.ref == 'refs/heads/develop' - working-directory: ether-charts - run: | - sed -i 's/tag: ".*"/tag: "${{ steps.build-docker.outputs.digest }}"/' values-dev.yaml - - name: Commit and push changes - working-directory: ether-charts - if: success() && github.ref == 'refs/heads/develop' - run: | - git config --global user.name 'github-actions[bot]' - git config --global user.email 'github-actions[bot]@users.noreply.github.com' - git add values-dev.yaml - git commit -m 'Update develop image tag' - git push diff --git a/.github/workflows/dockerfile.yml b/.github/workflows/dockerfile.yml new file mode 100644 index 000000000..5f8384705 --- /dev/null +++ b/.github/workflows/dockerfile.yml @@ -0,0 +1,26 @@ +name: "Dockerfile" + +# any branch is useful for testing before a PR is submitted +on: [push, pull_request] + +jobs: + dockerfile: + # run on pushes to any branch + # run on PRs from external forks + if: | + (github.event_name != 'pull_request') + || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) + name: build image and run connectivity test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: docker build + run: | + docker build -t etherpad:test . + docker run -d -p 9001:9001 etherpad:test + ./src/bin/installDeps.sh + sleep 3 # delay for startup? + cd src && npm run test-container diff --git a/.github/workflows/frontend-admin-tests.yml b/.github/workflows/frontend-admin-tests.yml index 4963fac24..e42aa3bb2 100644 --- a/.github/workflows/frontend-admin-tests.yml +++ b/.github/workflows/frontend-admin-tests.yml @@ -1,153 +1,58 @@ -# Leave the powered by Sauce Labs bit in as this means we get additional concurrency -name: "Frontend admin tests powered by Sauce Labs" +name: "Frontend admin tests" -on: - push: - paths-ignore: - - 'doc/**' - -permissions: - contents: read # to fetch code (actions/checkout) +on: [push] jobs: withplugins: name: with plugins runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [20, 22, 23] - steps: - - - name: Generate Sauce Labs strings - id: sauce_strings - run: | - printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }} - Node ${{ matrix.node }}' - printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}-node${{ matrix.node }}' - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node }} - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Cache playwright binaries - uses: actions/cache@v4 - id: playwright-cache - with: - path: | - ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - #- - # name: Install etherpad plugins - # # We intentionally install an old ep_align version to test upgrades to - # # the minor version number. The --legacy-peer-deps flag is required to - # # work around a bug in npm v7: https://github.com/npm/cli/issues/2199 - # run: pnpm install --workspace-root ep_align@0.2.27 - # Etherpad core dependencies must be installed after installing the - # plugin's dependencies, otherwise npm will try to hoist common - # dependencies by removing them from src/node_modules and installing them - # in the top-level node_modules. As of v6.14.10, npm's hoist logic appears - # to be buggy, because it sometimes removes dependencies from - # src/node_modules but fails to add them to the top-level node_modules. - # Even if npm correctly hoists the dependencies, the hoisting seems to - # confuse tools such as `npm outdated`, `npm update`, and some ESLint - # rules. - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: pnpm i - #- - # name: Install etherpad plugins - # run: rm -Rf node_modules/ep_align/static/tests/* - - - name: export GIT_HASH to env - id: environment - run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})" - - - name: Create settings.json - run: cp settings.json.template settings.json - - - name: Write custom settings.json that enables the Admin UI tests - run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme1\",\"is_admin\":true}}/' settings.json" - - - name: increase maxHttpBufferSize - run: "sed -i 's/\"maxHttpBufferSize\": 50000/\"maxHttpBufferSize\": 10000000/' settings.json" - - - name: Disable import/export rate limiting - run: | - sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json - - name: Build admin frontend - working-directory: admin - run: | - pnpm run build - # name: Run the frontend admin tests - # shell: bash - # env: - # SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} - # SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} - # SAUCE_NAME: ${{ steps.sauce_strings.outputs.name }} - # TRAVIS_JOB_NUMBER: ${{ steps.sauce_strings.outputs.tunnel_id }} - # GIT_HASH: ${{ steps.environment.outputs.sha_short }} - # run: | - # src/tests/frontend/travis/adminrunner.sh - #- - # uses: saucelabs/sauce-connect-action@v2.3.6 - # with: - # username: ${{ secrets.SAUCE_USERNAME }} - # accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} - # tunnelIdentifier: ${{ steps.sauce_strings.outputs.tunnel_id }} - #- - # name: Run the frontend admin tests - # shell: bash - # env: - # SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} - # SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} - # SAUCE_NAME: ${{ steps.sauce_strings.outputs.name }} - # TRAVIS_JOB_NUMBER: ${{ steps.sauce_strings.outputs.tunnel_id }} - # GIT_HASH: ${{ steps.environment.outputs.sha_short }} - # run: | - # src/tests/frontend/travis/adminrunner.sh - - name: Run the frontend admin tests - shell: bash - run: | - pnpm run prod & - connected=false - can_connect() { - curl -sSfo /dev/null http://localhost:9001/ || return 1 - connected=true - } - now() { date +%s; } - start=$(now) - while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do - sleep 1 - done - cd src - pnpm exec playwright install - pnpm exec playwright install-deps - pnpm run test-admin - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report-${{ matrix.node }} - path: src/playwright-report/ - retention-days: 30 + - name: Checkout repository + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - name: Run sauce-connect-action + shell: bash + env: + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + TRAVIS_JOB_NUMBER: ${{ github.run_id }}-${{ github.run_number }}-${{ github.job }} + run: src/tests/frontend/travis/sauce_tunnel.sh + + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + # We intentionally install a much old ep_align version to test update minor versions + - name: Install etherpad plugins + run: npm install ep_align@0.2.27 + + # Nuke plugin tests + - name: Install etherpad plugins + run: rm -Rf node_modules/ep_align/static/tests/* + + - name: export GIT_HASH to env + id: environment + run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})" + + - name: Write custom settings.json with loglevel WARN + run: "sed 's/\"loglevel\": \"INFO\",/\"loglevel\": \"WARN\",/' < settings.json.template > settings.json" + + - name: Write custom settings.json that enables the Admin UI tests + run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json" + + - name: Remove standard frontend test files, so only admin tests are run + run: mv src/tests/frontend/specs/* /tmp && mv /tmp/admin*.js src/tests/frontend/specs + + - name: Run the frontend admin tests + shell: bash + env: + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + TRAVIS_JOB_NUMBER: ${{ github.run_id }}-${{ github.run_number }}-${{ github.job }} + GIT_HASH: ${{ steps.environment.outputs.sha_short }} + run: | + src/tests/frontend/travis/adminrunner.sh diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index c11058b24..00c8dd6b5 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -1,238 +1,115 @@ -# Leave the powered by Sauce Labs bit in as this means we get additional concurrency -name: "Frontend tests powered by Sauce Labs" +name: "Frontend tests" -on: - push: - paths-ignore: - - 'doc/**' - -permissions: - contents: read # to fetch code (actions/checkout) +on: [push] jobs: - playwright-chrome: - name: Playwright Chrome - runs-on: ubuntu-latest - steps: - - - name: Generate Sauce Labs strings - id: sauce_strings - run: | - printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}' - printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}' - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - if: always() - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installDeps.sh - - - name: export GIT_HASH to env - id: environment - run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})" - - - name: Create settings.json - run: cp ./src/tests/settings.json settings.json - - name: Cache playwright binaries - uses: actions/cache@v4 - id: playwright-cache - with: - path: | - ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} - - name: Run the frontend tests - shell: bash - run: | - pnpm run prod & - connected=false - can_connect() { - curl -sSfo /dev/null http://localhost:9001/ || return 1 - connected=true - } - now() { date +%s; } - start=$(now) - while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do - sleep 1 - done - cd src - pnpm exec playwright install chromium --with-deps - pnpm run test-ui --project=chromium - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report-${{ matrix.node }}-chrome - path: src/playwright-report/ - retention-days: 30 - playwright-firefox: - name: Playwright Firefox - runs-on: ubuntu-latest - steps: - - name: Generate Sauce Labs strings - id: sauce_strings - run: | - printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}' - printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}' - - name: Checkout repository - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - if: always() - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installDeps.sh - - name: export GIT_HASH to env - id: environment - run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})" - - name: Create settings.json - run: cp ./src/tests/settings.json settings.json - - name: Cache playwright binaries - uses: actions/cache@v4 - id: playwright-cache - with: - path: | - ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} - - name: Run the frontend tests - shell: bash - run: | - pnpm run prod & - connected=false - can_connect() { - curl -sSfo /dev/null http://localhost:9001/ || return 1 - connected=true - } - now() { date +%s; } - start=$(now) - while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do - sleep 1 - done - cd src - pnpm exec playwright install firefox --with-deps - pnpm run test-ui --project=firefox - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report-${{ matrix.node }}-firefox - path: src/playwright-report/ - retention-days: 30 - playwright-webkit: - name: Playwright Webkit + withoutplugins: + name: without plugins runs-on: ubuntu-latest steps: - - - name: Generate Sauce Labs strings - id: sauce_strings - run: | - printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}' - printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}' - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Cache playwright binaries - uses: actions/cache@v4 - id: playwright-cache - with: - path: | - ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - if: always() - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installDeps.sh - - - name: export GIT_HASH to env - id: environment - run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})" - - - name: Create settings.json - run: cp ./src/tests/settings.json settings.json - - name: Run the frontend tests - shell: bash - run: | - pnpm run prod & - connected=false - can_connect() { - curl -sSfo /dev/null http://localhost:9001/ || return 1 - connected=true - } - now() { date +%s; } - start=$(now) - while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do - sleep 1 - done - cd src - pnpm exec playwright install webkit --with-deps - pnpm run test-ui --project=webkit || true - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report-${{ matrix.node }}-webkit - path: src/playwright-report/ - retention-days: 30 + - name: Checkout repository + uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: 12 + - name: Run sauce-connect-action + shell: bash + env: + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + TRAVIS_JOB_NUMBER: ${{ github.run_id }}-${{ github.run_number }}-${{ github.job }} + run: src/tests/frontend/travis/sauce_tunnel.sh + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + - name: export GIT_HASH to env + id: environment + run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})" + + - name: Write custom settings.json with loglevel WARN + run: "sed 's/\"loglevel\": \"INFO\",/\"loglevel\": \"WARN\",/' < settings.json.template > settings.json" + + - name: Run the frontend tests + shell: bash + env: + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + TRAVIS_JOB_NUMBER: ${{ github.run_id }}-${{ github.run_number }}-${{ github.job }} + GIT_HASH: ${{ steps.environment.outputs.sha_short }} + run: | + src/tests/frontend/travis/runner.sh + + withplugins: + name: with plugins + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - name: Run sauce-connect-action + shell: bash + env: + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + TRAVIS_JOB_NUMBER: ${{ github.run_id }}-${{ github.run_number }}-${{ github.job }} + run: src/tests/frontend/travis/sauce_tunnel.sh + + - name: Install Etherpad plugins + run: > + npm install + ep_align + ep_author_hover + ep_cursortrace + ep_font_size + ep_hash_auth + ep_headings2 + ep_image_upload + ep_markdown + ep_readonly_guest + ep_set_title_on_pad + ep_spellcheck + ep_subscript_and_superscript + ep_table_of_contents + + # This must be run after installing the plugins, otherwise npm will try to + # hoist common dependencies by removing them from src/node_modules and + # installing them in the top-level node_modules. As of v6.14.10, npm's hoist + # logic appears to be buggy, because it sometimes removes dependencies from + # src/node_modules but fails to add them to the top-level node_modules. Even + # if npm correctly hoists the dependencies, the hoisting seems to confuse + # tools such as `npm outdated`, `npm update`, and some ESLint rules. + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + - name: export GIT_HASH to env + id: environment + run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})" + + - name: Write custom settings.json with loglevel WARN + run: "sed 's/\"loglevel\": \"INFO\",/\"loglevel\": \"WARN\",/' < settings.json.template > settings.json" + + - name: Write custom settings.json that enables the Admin UI tests + run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json" + + # XXX we should probably run all tests, because plugins could effect their results + - name: Remove standard frontend test files, so only plugin tests are run + run: rm src/tests/frontend/specs/* + + - name: Run the frontend tests + shell: bash + env: + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + TRAVIS_JOB_NUMBER: ${{ github.run_id }}-${{ github.run_number }}-${{ github.job }} + GIT_HASH: ${{ steps.environment.outputs.sha_short }} + run: | + src/tests/frontend/travis/runner.sh diff --git a/.github/workflows/lint-package-lock.yml b/.github/workflows/lint-package-lock.yml new file mode 100644 index 000000000..a9596aa3c --- /dev/null +++ b/.github/workflows/lint-package-lock.yml @@ -0,0 +1,28 @@ +name: "Lint" + +# any branch is useful for testing before a PR is submitted +on: [push, pull_request] + +jobs: + lint-package-lock: + # run on pushes to any branch + # run on PRs from external forks + if: | + (github.event_name != 'pull_request') + || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) + name: package-lock.json + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - name: Install lockfile-lint + run: npm install lockfile-lint + + - name: Run lockfile-lint on package-lock.json + run: npx lockfile-lint --path src/package-lock.json --validate-https --allowed-hosts npm diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 50847cd9a..98379dfe8 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -1,173 +1,81 @@ name: "Loadtest" # any branch is useful for testing before a PR is submitted -on: - push: - paths-ignore: - - "doc/**" - pull_request: - paths-ignore: - - "doc/**" - -permissions: - contents: read +on: [push, pull_request] jobs: withoutplugins: # run on pushes to any branch # run on PRs from external forks if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) + (github.event_name != 'pull_request') + || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) name: without plugins runs-on: ubuntu-latest + steps: - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installDeps.sh - - - name: Install etherpad-load-test - run: sudo npm install -g etherpad-load-test-socket-io - - - name: Run load test - run: src/tests/frontend/travis/runnerLoadTest.sh 25 50 + - name: Checkout repository + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + - name: Install etherpad-load-test + run: sudo npm install -g etherpad-load-test + + - name: Run load test + run: src/tests/frontend/travis/runnerLoadTest.sh withplugins: # run on pushes to any branch # run on PRs from external forks if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) + (github.event_name != 'pull_request') + || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) name: with Plugins runs-on: ubuntu-latest - steps: - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install etherpad-load-test - run: pnpm install -g etherpad-load-test-socket-io - - - name: Install etherpad plugins - # The --legacy-peer-deps flag is required to work around a bug in npm v7: - # https://github.com/npm/cli/issues/2199 - run: > - pnpm install --workspace-root - ep_align - ep_author_hover - ep_cursortrace - ep_font_size - ep_hash_auth - ep_headings2 - ep_markdown - ep_readonly_guest - ep_set_title_on_pad - ep_spellcheck - ep_subscript_and_superscript - ep_table_of_contents - # Etherpad core dependencies must be installed after installing the - # plugin's dependencies, otherwise npm will try to hoist common - # dependencies by removing them from src/node_modules and installing them - # in the top-level node_modules. As of v6.14.10, npm's hoist logic appears - # to be buggy, because it sometimes removes dependencies from - # src/node_modules but fails to add them to the top-level node_modules. - # Even if npm correctly hoists the dependencies, the hoisting seems to - # confuse tools such as `npm outdated`, `npm update`, and some ESLint - # rules. - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installDeps.sh - - - name: Run load test - run: src/tests/frontend/travis/runnerLoadTest.sh 25 50 - long: - # run on pushes to any branch - # run on PRs from external forks - if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) - name: long running - runs-on: ubuntu-latest steps: - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installDeps.sh - - - name: Install etherpad-load-test - run: sudo npm install -g etherpad-load-test-socket-io - - - name: Run load test - run: src/tests/frontend/travis/runnerLoadTest.sh 5000 5 + - name: Checkout repository + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - name: Install etherpad-load-test + run: sudo npm install -g etherpad-load-test + + - name: Install etherpad plugins + run: > + npm install + ep_align + ep_author_hover + ep_cursortrace + ep_font_size + ep_hash_auth + ep_headings2 + ep_markdown + ep_readonly_guest + ep_set_title_on_pad + ep_spellcheck + ep_subscript_and_superscript + ep_table_of_contents + + # This must be run after installing the plugins, otherwise npm will try to + # hoist common dependencies by removing them from src/node_modules and + # installing them in the top-level node_modules. As of v6.14.10, npm's hoist + # logic appears to be buggy, because it sometimes removes dependencies from + # src/node_modules but fails to add them to the top-level node_modules. Even + # if npm correctly hoists the dependencies, the hoisting seems to confuse + # tools such as `npm outdated`, `npm update`, and some ESLint rules. + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + # configures some settings and runs npm run test + - name: Run load test + run: src/tests/frontend/travis/runnerLoadTest.sh diff --git a/.github/workflows/perform-type-check.yml b/.github/workflows/perform-type-check.yml deleted file mode 100644 index ba35dec32..000000000 --- a/.github/workflows/perform-type-check.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: "Perform type checks" - -# any branch is useful for testing before a PR is submitted -on: - push: - paths-ignore: - - "doc/**" - pull_request: - paths-ignore: - - "doc/**" - -permissions: - contents: read - - -jobs: - performTypeCheck: - if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) - name: perform type check - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: ./bin/installDeps.sh - - name: Perform type check - working-directory: ./src - run: npm run ts-check diff --git a/.github/workflows/rate-limit.yml b/.github/workflows/rate-limit.yml index 003a10000..0849f8e06 100644 --- a/.github/workflows/rate-limit.yml +++ b/.github/workflows/rate-limit.yml @@ -1,71 +1,43 @@ name: "rate limit" # any branch is useful for testing before a PR is submitted -on: - push: - paths-ignore: - - "doc/**" - pull_request: - paths-ignore: - - "doc/**" - -permissions: - contents: read +on: [push, pull_request] jobs: ratelimit: # run on pushes to any branch # run on PRs from external forks if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) + (github.event_name != 'pull_request') + || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) name: test runs-on: ubuntu-latest steps: - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- + - name: Checkout repository + uses: actions/checkout@v2 - - - name: docker network - run: docker network create --subnet=172.23.0.0/16 ep_net - - - name: build docker image - run: | - docker build -f Dockerfile -t epl-debian-slim --build-arg NODE_ENV=develop . - docker build -f src/tests/ratelimit/Dockerfile.nginx -t nginx-latest . - docker build -f src/tests/ratelimit/Dockerfile.anotherip -t anotherip . - - - name: run docker images - run: | - docker run --name etherpad-docker -p 9000:9001 --rm --network ep_net --ip 172.23.42.2 -e 'TRUST_PROXY=true' epl-debian-slim & - docker run -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest - docker run --rm --network ep_net --ip 172.23.42.3 --name anotherip -dt anotherip - - - name: install dependencies and create symlink for ep_etherpad-lite - run: bin/installDeps.sh - - - name: run rate limit test - run: | - cd src/tests/ratelimit - ./testlimits.sh + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - name: docker network + run: docker network create --subnet=172.23.42.0/16 ep_net + + - name: build docker image + run: | + docker build -f Dockerfile -t epl-debian-slim . + docker build -f src/tests/ratelimit/Dockerfile.nginx -t nginx-latest . + docker build -f src/tests/ratelimit/Dockerfile.anotherip -t anotherip . + - name: run docker images + run: | + docker run --name etherpad-docker -p 9000:9001 --rm --network ep_net --ip 172.23.42.2 -e 'TRUST_PROXY=true' epl-debian-slim & + docker run -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest + docker run --rm --network ep_net --ip 172.23.42.3 --name anotherip -dt anotherip + + - name: install dependencies and create symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + - name: run rate limit test + run: | + cd src/tests/ratelimit + ./testlimits.sh diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 60249bb15..000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: 'Close stale issues and PRs' -on: - schedule: - - cron: '30 6 * * *' -permissions: - issues: write - pull-requests: write -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v9 - with: - close-issue-label: wontfix - close-pr-label: wontfix - days-before-close: -1 - exempt-issue-labels: 'pinned,security,Bug,Serious Bug,Minor bug,Black hole bug,Special case Bug,Upstream bug,Feature Request' - exempt-pr-labels: 'pinned,security,Bug,Serious Bug,Minor bug,Black hole bug,Special case Bug,Upstream bug,Feature Request' diff --git a/.github/workflows/upgrade-from-latest-release.yml b/.github/workflows/upgrade-from-latest-release.yml deleted file mode 100644 index 08421e7ec..000000000 --- a/.github/workflows/upgrade-from-latest-release.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: "Upgrade from latest release" - -# any branch is useful for testing before a PR is submitted -on: - push: - paths-ignore: - - "doc/**" - pull_request: - paths-ignore: - - "doc/**" - -permissions: - contents: read - -jobs: - withpluginsLinux: - # run on pushes to any branch - # run on PRs from external forks - if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) - name: Linux with Plugins - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [20, 22, 23] - steps: - - - name: Check out latest release - uses: actions/checkout@v4 - with: - ref: develop #FIXME change to master when doing release - - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node }} - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.5.0 - with: - packages: libreoffice libreoffice-pdfimport - version: 1.0 - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.5.0 - with: - packages: libreoffice libreoffice-pdfimport - version: 1.0 - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: bin/installDeps.sh - - name: Install admin ui - working-directory: admin - run: pnpm install - - name: Build admin ui - working-directory: admin - run: pnpm build - - - name: Install Etherpad plugins - run: > - pnpm run install-plugins - ep_align - ep_author_hover - ep_cursortrace - ep_font_size - ep_hash_auth - ep_headings2 - ep_markdown - ep_readonly_guest - ep_set_title_on_pad - ep_spellcheck - ep_subscript_and_superscript - ep_table_of_contents - - - name: Run the backend tests - run: pnpm run test - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: ./bin/installDeps.sh - # Because actions/checkout@v4 is called with "ref: master" and without - # "fetch-depth: 0", the local clone does not have the ${GITHUB_SHA} - # commit. Fetch ${GITHUB_REF} to get the ${GITHUB_SHA} commit. Note that a - # plain "git fetch" only fetches "normal" references (refs/heads/* and - # refs/tags/*), and for pull requests none of the normal references - # include ${GITHUB_SHA}, so we have to explicitly tell Git to fetch - # ${GITHUB_REF}. - - - name: Fetch the new Git commits - run: git fetch --depth=1 origin "${GITHUB_REF}" - - - name: Upgrade to the new Git revision - # For pull requests, ${GITHUB_SHA} is the automatically generated merge - # commit that merges the PR's source branch to its destination branch. - run: git checkout "${GITHUB_SHA}" - - name: Run the backend tests - run: pnpm run test diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml deleted file mode 100644 index c0ce8b8bd..000000000 --- a/.github/workflows/windows.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: "Windows Build" - -# any branch is useful for testing before a PR is submitted -on: - push: - paths-ignore: - - "doc/**" - pull_request: - paths-ignore: - - "doc/**" - -permissions: - contents: read - -jobs: - build-zip: - permissions: write-all - # run on pushes to any branch - # run on PRs from external forks - if: | - (github.event_name != 'pull_request') - || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) - name: Build .zip - runs-on: windows-latest - steps: - - - uses: msys2/setup-msys2@v2 - with: - path-type: inherit - install: >- - zip - - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 9.0.4 - run_install: false - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Only install direct dependencies - run: pnpm config set auto-install-peers false - - - name: Install all dependencies and symlink for ep_etherpad-lite - shell: msys2 {0} - run: bin/installDeps.sh - - - name: Run the backend tests - shell: msys2 {0} - working-directory: src - run: pnpm test - - - name: Run Etherpad - working-directory: src - run: | - pnpm i - pnpm exec playwright install --with-deps - pnpm run prod & - curl --connect-timeout 10 --max-time 20 --retry 5 --retry-delay 10 --retry-max-time 60 --retry-connrefused http://127.0.0.1:9001/p/test - pnpm exec playwright install chromium --with-deps - pnpm run test-ui --project=chromium - # On release, create release - - name: Generate Changelog - if: ${{startsWith(github.ref, 'refs/tags/v') }} - working-directory: bin - run: pnpm run generateChangelog ${{ github.ref }} > ${{ github.workspace }}-CHANGELOG.txt - - name: Release - uses: softprops/action-gh-release@v2 - if: ${{startsWith(github.ref, 'refs/tags/v') }} - with: - body_path: ${{ github.workspace }}-CHANGELOG.txt - make_latest: true diff --git a/.gitignore b/.gitignore index 71584e76b..09618cc83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,15 @@ -/etherpad-win.exe -/etherpad-win.zip node_modules /settings.json !settings.json.template APIKEY.txt SESSIONKEY.txt +etherpad-lite-win.zip var/dirty.db -.env *~ *.patch npm-debug.log *.DS_Store +.ep_initialized *.crt *.key credentials.json @@ -22,9 +21,3 @@ out/ /src/bin/convertSettings.json /src/bin/etherpad-1.deb /src/bin/node.exe -plugin_packages -/src/templates/admin -/src/test-results -playwright-report -state.json -/src/static/oidc diff --git a/.lgtm.yml b/.lgtm.yml deleted file mode 100644 index 9dd0f16b6..000000000 --- a/.lgtm.yml +++ /dev/null @@ -1,9 +0,0 @@ -extraction: - javascript: - index: - exclude: - - src/static/js/vendors - python: - index: - exclude: - - / diff --git a/.npmrc b/.npmrc deleted file mode 100644 index f301fedf9..000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -auto-install-peers=false diff --git a/.travis.yml b/.travis.yml index ca8c5380f..99aece0b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,10 +28,8 @@ _install_libreoffice: &install_libreoffice >- sudo apt-get update && sudo apt-get -y install libreoffice libreoffice-pdfimport -# The --legacy-peer-deps flag is required to work around a bug in npm v7: -# https://github.com/npm/cli/issues/2199 _install_plugins: &install_plugins >- - npm install --no-save --legacy-peer-deps + npm install ep_align ep_author_hover ep_cursortrace @@ -55,7 +53,7 @@ jobs: - *set_loglevel_warn - *enable_admin_tests - "src/tests/frontend/travis/sauce_tunnel.sh" - - "bin/installDeps.sh" + - "src/bin/installDeps.sh" - "export GIT_HASH=$(git rev-parse --verify --short HEAD)" script: - "./src/tests/frontend/travis/runner.sh" @@ -63,22 +61,22 @@ jobs: install: - *install_libreoffice - *set_loglevel_warn - - "bin/installDeps.sh" - - "cd src && pnpm install && cd -" + - "src/bin/installDeps.sh" + - "cd src && npm install && cd -" script: - - "cd src && pnpm test" + - "cd src && npm test" - name: "Test the Dockerfile" install: - - "cd src && pnpm install && cd -" + - "cd src && npm install && cd -" script: - "docker build -t etherpad:test ." - "docker run -d -p 9001:9001 etherpad:test && sleep 3" - - "cd src && pnpm run test-container" + - "cd src && npm run test-container" - name: "Load test Etherpad without Plugins" install: - *set_loglevel_warn - - "bin/installDeps.sh" - - "cd src && pnpm install && cd -" + - "src/bin/installDeps.sh" + - "cd src && npm install && cd -" - "npm install -g etherpad-load-test" script: - "src/tests/frontend/travis/runnerLoadTest.sh" @@ -90,7 +88,7 @@ jobs: - *set_loglevel_warn - *enable_admin_tests - "src/tests/frontend/travis/sauce_tunnel.sh" - - "bin/installDeps.sh" + - "src/bin/installDeps.sh" - "rm src/tests/frontend/specs/*" - *install_plugins - "export GIT_HASH=$(git rev-parse --verify --short HEAD)" @@ -105,22 +103,22 @@ jobs: install: - *install_libreoffice - *set_loglevel_warn - - "bin/installDeps.sh" + - "src/bin/installDeps.sh" - *install_plugins - - "cd src && pnpm install && cd -" + - "cd src && npm install && cd -" script: - - "cd src && pnpm test" + - "cd src && npm test" - name: "Test the Dockerfile" install: - - "cd src && pnpm install && cd -" + - "cd src && npm install && cd -" script: - "docker build -t etherpad:test ." - "docker run -d -p 9001:9001 etherpad:test && sleep 3" - - "cd src && pnpm run test-container" + - "cd src && npm run test-container" - name: "Load test Etherpad with Plugins" install: - *set_loglevel_warn - - "bin/installDeps.sh" + - "src/bin/installDeps.sh" - *install_plugins - "cd src && npm install && cd -" - "npm install -g etherpad-load-test" @@ -135,7 +133,7 @@ jobs: - "docker run -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest" - "docker run --name etherpad-docker -p 9000:9001 --rm --network ep_net --ip 172.23.42.2 -e 'TRUST_PROXY=true' epl-debian-slim &" - "docker run --rm --network ep_net --ip 172.23.42.3 --name anotherip -dt anotherip" - - "./bin/installDeps.sh" + - "./src/bin/installDeps.sh" script: - "cd src/tests/ratelimit && bash testlimits.sh" diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a70e7dd..a17488f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,684 +1,3 @@ -# 2.3.0 - -### Notable enhancements and fixes - -- Added possibility to cluster Etherpads behind reverse proxy. There is now a new reverse proxy designed for Etherpads that handles multiple Etherpads and the created pads in them. It will assign the pad assignement to an Etherpad at random but once the choice was made it will always reverse proxy the same backend. This allows to host multiple concurrent Etherpads and benefit from multi core systems even though one Etherpad is singlethreaded. -- Added reverse proxy configuration for replacing Nginx. In the past there were some issues with nginx and its configuration. This reverse proxy allows you to handle your configuration with ease. - -If you want to find out more about the reverse proxy method check out the repository https://github.com/ether/etherpad-proxy . It also contains a sample docker-compose file with three Etherpads and one etherpad-proxy. Of course you need to adapt the settings.json.template to your liking and map it into the reverse proxy image before you are ready :). - - -- Added client authorization to work with Etherpad. Before it would get blocked because it doesn't have the required claim. As this is now fixed etherpad-proxy can also work with your new OAuth2 configuration and retrieve a token via client credentials flow. - - - - -# 2.2.7 - - -### Notable enhancements and fixes - -- We migrated all important pages to React 19 and React Router v7 - -Besides that only dependency updates. - - - -> Have a merry Christmas and a happy new year. 🎄 🎁 - - -# 2.2.6 - -### Notable enhancements and fixes - -- Added option to delete a pad by the creator. This option can be found in the settings menu. When you click on it you get a confirm dialog and after that you have the chance to completely erase the pad. - - -# 2.2.5 - -### Notable enhancements and fixes - -- Fixed timeslider not scrolling when the revision count is a multiple of 100 -- Added new Restful API for version 2 of Etherpad. It is available at /api-docs - - -# 2.2.4 - -### Notable enhancements and fixes - -- Switched to new SQLite backend -- Fixed rusty-store-kv module not found - - -# 2.2.3 - -### Notable enhancements and fixes - -- Introduced a new in process database `rustydb` that represents a fast key value store written in Rust. -- Readded window._ as a shortcut for getting text -- Added support for migrating any ueberdb database to another. You can now switch as you please. See here: https://docs.etherpad.org/cli.html -- Further Typescript movements -- A lot of security issues fixed and reviewed in this release. Please update. - - -# 2.2.2 - -### Notable enhancements and fixes - -- Removal of Etherpad require kernel: We finally managed to include esbuild to bundle our frontend code together. So no matter how many plugins your server has it is always one JavaScript file. This boosts performance dramatically. -- Added log layoutType: This lets you print the log in either colored or basic (black and white text) -- Introduced esbuild for bundling CSS files -- Cache all files to be bundled in memory for faster load speed - - -# 2.1.1 - - -### Notable enhancements and fixes - -- Fixed failing Docker build when checked out as git submodule. Thanks to @neurolabs -- Fixed: Fallback to websocket and polling when unknown(old) config is present for socket io -- Fixed: Next page disabled if zero page by @samyakj023 -- On CTRL+CLICK bring the window back to focus by Helder Sepulveda - -# 2.1.0 - -### Notable enhancements and fixes - -- Added PWA support. You can now add your Etherpad instance to your home screen on your mobile device or desktop. -- Fixed live plugin manager versions clashing. Thanks to @yacchin1205 -- Fixed a bug in the pad panel where pagination was not working correctly when sorting by pad name - -### Compatibility changes - -- Reintroduced APIKey.txt support. You can now switch between APIKey and OAuth2.0 authentication. This can be toggled with the setting authenticationMethod. The default is OAuth2. If you want to use the APIKey method you can set that to `apikey`. - - -# 2.0.3 - -### Notable enhancements and fixes - -- Added documentation for replacing apikeys with oauth2 -- Bumped live plugin manager to 0.20.0. Thanks to @fgreinacher -- Added better documentation for using docker-compose with Etherpad - - - -# 2.0.2 - -### Notable enhancements and fixes - -- Fixed the locale loading in the admin panel -- Added OAuth2.0 support for the Etherpad API. You can now log in into the Etherpad API with your admin user using OAuth2 - -### Compatibility changes - -- The tests now require generating a token from the OAuth secret. You can find the `generateJWTToken` in the common.ts script for plugin endpoint updates. - - -# 2.0.1 - -### Notable enhancements and fixes - -- Fixed a bug where a plugin depending on a scoped dependency would not install successfully. - - -# 2.0.0 - - -### Compatibility changes - -- Socket io has been updated to 4.7.5. This means that the json.send function won't work anymore and needs to be changed to .emit('message', myObj) -- Deprecating npm version 6 in favor of pnpm: We have made the decision to switch to the well established pnpm (https://pnpm.io/). It works by symlinking dependencies into a global directory allowing you to have a cleaner and more reliable environment. -- Introducing Typescript to the Etherpad core: Etherpad core logic has been rewritten in Typescript allowing for compiler checking of errors. -- Rewritten Admin Panel: The Admin panel has been rewritten in React and now features a more pleasant user experience. It now also features an integrated pad searching with sorting functionality. - -### Notable enhancements and fixes - -* Bugfixes - - Live Plugin Manager: The live plugin manager caused problems when a plugin had depdendencies defined. This issue is now resolved. - -* Enhancements - - pnpm Workspaces: In addition to pnpm we introduced workspaces. A clean way to manage multiple bounded contexts like the admin panel or the bin folder. - - Bin folder: The bin folder has been moved from the src folder to the root folder. This change was necessary as the contained scripts do not represent core functionality of the user. - - Starting Etherpad: Etherpad can now be started with a single command: `pnpm run prod` in the root directory. - - Installing Etherpad: Etherpad no longer symlinks itself in the root directory. This is now also taken care by pnpm, and it just creates a node_modules folder with the src directory`s ep_etherpad-lite folder - - Plugins can now be installed simply via the command: `pnpm run plugins i first-plugin second-plugin` or if you want to install from path you can do: - `pnpm run plugins i --path ../path-to-plugin` - - -# 1.9.7 - -### Notable enhancements and fixes - -* Added Live Plugin Manager: Plugins are now installed into a separate folder on the host system. This folder is called `plugin_packages`. -That way the plugins are separated from the normal etherpad installation. -* Make repairPad.js more verbose -* Fixed favicon not being loaded correctly - -# 1.9.6 - -### Notable enhancements and fixes - -* Prevent etherpad crash when update server is not reachable -* Use npm@6 in Docker build -* Fix setting the log level in settings.json - - -# 1.9.5 - -### Compatibility changes - -* This version deprecates NodeJS16 as it reached its end of life and won't receive any updates. So to get started with Etherpad v1.9.5 you need NodeJS 18 and above. -* The bundled windows NodeJS version has been bumped to the current LTS version 20. - -### Notable enhancements and fixes - -* The support for the tidy program to tidy up HTML files has been removed. This decision was made because it hasn't been updated for years and also caused an incompability when exporting a pad with Abiword. - - -# 1.9.4 - -### Compatibility changes - -* Log4js has been updated to the latest version. As it involved a bump of 6 major version. - A lot has changed since then. Most notably the console appender has been deprecated. You can find out more about it [here](https://github.com/log4js-node/log4js-node) - -### Notable enhancements and fixes - -* Fix for MySQL: The logger calls were incorrectly configured leading to a crash when e.g. somebody uses a different encoding than standard MySQL encoding. - -# 1.9.3 - -### Compability changes - -* express-rate-limit has been bumped to 7.0.0: This involves the breaking change that "max: 0" -in the importExportRateLimiting is set to always trigger. So set it to your desired value. -If you haven't changed that value in the settings.json you are all set. - -### Notable enhancements and fixes - -* Bugfixes - * Fix etherpad crashing with mongodb database - -* Enhancements - * Add surrealdb database support. You can find out more about this database [here](https://surrealdb.com). - * Make sqlite faster: The sqlite library has been switched to better-sqlite3. This should lead to better performance. - -# 1.9.2 - -### Notable enhancements and fixes - -* Security - * Enable session key rotation: This setting can be enabled in the settings.json. It changes the signing key for the cookie authentication in a fixed interval. - -* Bugfixes - * Fix appendRevision when creating a new pad via the API without a text. - - -* Enhancements - * Bump JQuery to version 3.7 - * Update elasticsearch connector to version 8 - -### Compatibility changes - -* No compability changes as JQuery maintains excellent backwards compatibility. - -#### For plugin authors - -* Please update to JQuery 3.7. There is an excellent deprecation guide over [here](https://api.jquery.com/category/deprecated/). Version 3.1 to 3.7 are relevant for the upgrade. - -# 1.9.1 - -### Notable enhancements and fixes - -* Security - * Limit requested revisions in timeslider and export to head revision. (affects v1.9.0) - -* Bugfixes - * revisions in `CHANGESET_REQ` (timeslider) and export (txt, html, custom) - are now checked to be numbers. - * bump sql for audit fix -* Enhancements - * Add keybinding meta-backspace to delete to beginning of line - * Fix automatic Windows build via GitHub Actions - * Enable docs to be build cross platform thanks to asciidoctor - -### Compatibility changes -* tests: drop windows 7 test coverage & use chrome latest for admin tests -* Require Node 16 for Etherpad and target Node 20 for testing - - -# 1.9.0 - -### Notable enhancements and fixes - -* Windows build: - * The bundled `node.exe` was upgraded from v12 to v16. - * The bundled `node.exe` is now a 64-bit executable. If you need the 32-bit - version you must download and install Node.js yourself. -* Improvements to login session management: - * `express_sid` cookies and `sessionstorage:*` database records are no longer - created unless `requireAuthentication` is `true` (or a plugin causes them to - be created). - * Login sessions now have a finite lifetime by default (10 days after - leaving). - * `sessionstorage:*` database records are automatically deleted when the login - session expires (with some exceptions that will be fixed in the future). - * Requests for static content (e.g., `/robots.txt`) and special pages (e.g., - the HTTP API, `/stats`) no longer create login session state. - * The secret used to sign the `express_sid` cookie is now automatically - regenerated every day (called *key rotation*) by default. If key rotation is - enabled, the now-deprecated `SESSIONKEY.txt` file can be safely deleted - after Etherpad starts up (its content is read and saved to the database and - used to validate signatures from old cookies until they expire). -* The following settings from `settings.json` are now applied as expected (they - were unintentionally ignored before): - * `padOptions.lang` - * `padOptions.showChat` - * `padOptions.userColor` - * `padOptions.userName` -* HTTP API: - * Fixed the return value of `getText` when called with a specific revision. - * Fixed a potential attribute pool corruption bug with - `copyPadWithoutHistory`. - * Mappings created by `createGroupIfNotExistsFor` are now removed from the - database when the group is deleted. - * Fixed race conditions in the `setText`, `appendText`, and `restoreRevision` - functions. - * Added an optional `authorId` parameter to `appendText`, - `copyPadWithoutHistory`, `createGroupPad`, `createPad`, `restoreRevision`, - `setHTML`, and `setText`, and bumped the latest API version to 1.3.0. -* Fixed a crash if the database is busy enough to cause a query timeout. -* New `/health` endpoint for getting information about Etherpad's health (see - [draft-inadarei-api-health-check-06](https://www.ietf.org/archive/id/draft-inadarei-api-health-check-06.html)). -* Docker now uses the new `/health` endpoint for health checks, which avoids - issues when authentication is enabled. It also avoids the unnecessary creation - of database records for managing browser sessions. -* When copying a pad, the pad's records are copied in batches to avoid database - timeouts with large pads. -* Exporting a large pad to `.etherpad` format should be faster thanks to bulk - database record fetches. -* When importing an `.etherpad` file, records are now saved to the database in - batches to avoid database timeouts with large pads. - -#### For plugin authors - -* New `expressPreSession` server-side hook. -* Pad server-side hook changes: - * `padCheck`: New hook. - * `padCopy`: New `srcPad` and `dstPad` context properties. - * `padDefaultContent`: New hook. - * `padRemove`: New `pad` context property. -* The `db` property on Pad objects is now public. -* New `getAuthorId` server-side hook. -* New APIs for processing attributes: `ep_etherpad-lite/static/js/attributes` - (low-level API) and `ep_etherpad-lite/static/js/AttributeMap` (high-level - API). -* The `import` server-side hook has a new `ImportError` context property. -* New `exportEtherpad` and `importEtherpad` server-side hooks. -* The `handleMessageSecurity` and `handleMessage` server-side hooks have a new - `sessionInfo` context property that includes the user's author ID, the pad ID, - and whether the user only has read-only access. -* The `handleMessageSecurity` server-side hook can now be used to grant write - access for the current message only. -* The `init_` server-side hooks have a new `logger` context - property that plugins can use to log messages. -* Prevent infinite loop when exiting the server -* Bump dependencies - - -### Compatibility changes - -* Node.js v14.15.0 or later is now required. -* The default login session expiration (applicable if `requireAuthentication` is - `true`) changed from never to 10 days after the user leaves. - -#### For plugin authors - -* The `client` context property for the `handleMessageSecurity` and - `handleMessage` server-side hooks is deprecated; use the `socket` context - property instead. -* Pad server-side hook changes: - * `padCopy`: - * The `originalPad` context property is deprecated; use `srcPad` instead. - * The `destinationID` context property is deprecated; use `dstPad.id` - instead. - * `padCreate`: The `author` context property is deprecated; use the new - `authorId` context property instead. Also, the hook now runs asynchronously. - * `padLoad`: Now runs when a temporary Pad object is created during import. - Also, it now runs asynchronously. - * `padRemove`: The `padID` context property is deprecated; use `pad.id` - instead. - * `padUpdate`: The `author` context property is deprecated; use the new - `authorId` context property instead. Also, the hook now runs asynchronously. -* Returning `true` from a `handleMessageSecurity` hook function is deprecated; - return `'permitOnce'` instead. -* Changes to the `src/static/js/Changeset.js` library: - * The following attribute processing functions are deprecated (use the new - attribute APIs instead): - * `attribsAttributeValue()` - * `eachAttribNumber()` - * `makeAttribsString()` - * `opAttributeValue()` - * `opIterator()`: Deprecated in favor of the new `deserializeOps()` generator - function. - * `appendATextToAssembler()`: Deprecated in favor of the new `opsFromAText()` - generator function. - * `newOp()`: Deprecated in favor of the new `Op` class. -* The `AuthorManager.getAuthor4Token()` function is deprecated; use the new - `AuthorManager.getAuthorId()` function instead. -* The exported database records covered by the `exportEtherpadAdditionalContent` - server-side hook now include keys like `${customPrefix}:${padId}:*`, not just - `${customPrefix}:${padId}`. -* Plugin locales should overwrite core's locales Stale -* Plugin locales overwrite core locales - -# 1.8.18 - -Released: 2022-05-05 - -### Notable enhancements and fixes - - * Upgraded ueberDB to fix a regression with CouchDB. - -# 1.8.17 - -Released: 2022-02-23 - -### Security fixes - -* Fixed a vunlerability in the `CHANGESET_REQ` message handler that allowed a - user with any access to read any pad if the pad ID is known. - -### Notable enhancements and fixes - -* Fixed a bug that caused all pad edit messages received at the server to go - through a single queue. Now there is a separate queue per pad as intended, - which should reduce message processing latency when many pads are active at - the same time. - -# 1.8.16 - -### Security fixes - -If you cannot upgrade to v1.8.16 for some reason, you are encouraged to try -cherry-picking the fixes to the version you are running: - -```shell -git cherry-pick b7065eb9a0ec..77bcb507b30e -``` - -* Maliciously crafted `.etherpad` files can no longer overwrite arbitrary - non-pad database records when imported. -* Imported `.etherpad` files are now subject to numerous consistency checks - before any records are written to the database. This should help avoid - denial-of-service attacks via imports of malformed `.etherpad` files. - -### Notable enhancements and fixes - -* Fixed several `.etherpad` import bugs. -* Improved support for large `.etherpad` imports. - -# 1.8.15 - -### Security fixes - -* Fixed leak of the writable pad ID when exporting from the pad's read-only ID. - This only matters if you treat the writeable pad IDs as secret (e.g., you are - not using [ep_padlist2](https://www.npmjs.com/package/ep_padlist2)) and you - share the pad's read-only ID with untrusted users. Instead of treating - writeable pad IDs as secret, you are encouraged to take advantage of - Etherpad's authentication and authorization mechanisms (e.g., use - [ep_openid_connect](https://www.npmjs.com/package/ep_openid_connect) with - [ep_readonly_guest](https://www.npmjs.com/package/ep_readonly_guest), or write - your own - [authentication](https://etherpad.org/doc/v1.8.14/#index_authenticate) and - [authorization](https://etherpad.org/doc/v1.8.14/#index_authorize) plugins). -* Updated dependencies. - -### Compatibility changes - -* The `logconfig` setting is deprecated. - -#### For plugin authors - -* Etherpad now uses [jsdom](https://github.com/jsdom/jsdom) instead of - [cheerio](https://cheerio.js.org/) for processing HTML imports. There are two - consequences of this change: - * `require('ep_etherpad-lite/node_modules/cheerio')` no longer works. To fix, - your plugin should directly depend on `cheerio` and do `require('cheerio')`. - * The `collectContentImage` hook's `node` context property is now an - [`HTMLImageElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement) - object rather than a Cheerio Node-like object, so the API is slightly - different. See - [citizenos/ep_image_upload#49](https://github.com/citizenos/ep_image_upload/pull/49) - for an example fix. -* The `clientReady` server-side hook is deprecated; use the new `userJoin` hook - instead. -* The `init_` server-side hooks are now run every time Etherpad - starts up, not just the first time after the named plugin is installed. -* The `userLeave` server-side hook's context properties have changed: - * `auth`: Deprecated. - * `author`: Deprecated; use the new `authorId` property instead. - * `readonly`: Deprecated; use the new `readOnly` property instead. - * `rev`: Deprecated. -* Changes to the `src/static/js/Changeset.js` library: - * `opIterator()`: The unused start index parameter has been removed, as has - the unused `lastIndex()` method on the returned object. - * `smartOpAssembler()`: The returned object's `appendOpWithText()` method is - deprecated without a replacement available to plugins (if you need one, let - us know and we can make the private `opsFromText()` function public). - * Several functions that should have never been public are no longer exported: - `applyZip()`, `assert()`, `clearOp()`, `cloneOp()`, `copyOp()`, `error()`, - `followAttributes()`, `opString()`, `stringOp()`, `textLinesMutator()`, - `toBaseTen()`, `toSplices()`. - -### Notable enhancements and fixes - -* Accessibility fix for JAWS screen readers. -* Fixed "clear authorship" error (see issue #5128). -* Etherpad now considers square brackets to be valid URL characters. -* The server no longer crashes if an exception is thrown while processing a - message from a client. -* The `useMonospaceFontGlobal` setting now works (thanks @Lastpixl!). -* Chat improvements: - * The message input field is now a text area, allowing multi-line messages - (use shift-enter to insert a newline). - * Whitespace in chat messages is now preserved. -* Docker improvements: - * New `HEALTHCHECK` instruction (thanks @Gared!). - * New `settings.json` variables: `DB_COLLECTION`, `DB_URL`, - `SOCKETIO_MAX_HTTP_BUFFER_SIZE`, `DUMP_ON_UNCLEAN_EXIT` (thanks - @JustAnotherArchivist!). - * `.ep_initialized` files are no longer created. -* Worked around a [Firefox Content Security Policy - bug](https://bugzilla.mozilla.org/show_bug.cgi?id=1721296) that caused CSP - failures when `'self'` was in the CSP header. See issue #4975 for details. -* UeberDB upgraded from v1.4.10 to v1.4.18. For details, see the [ueberDB - changelog](https://github.com/ether/ueberDB/blob/master/CHANGELOG.md). - Highlights: - * The `postgrespool` driver was renamed to `postgres`, replacing the old - driver of that name. If you used the old `postgres` driver, you may see an - increase in the number of database connections. - * For `postgres`, you can now set the `dbSettings` value in `settings.json` to - a connection string (e.g., `"postgres://user:password@host/dbname"`) instead - of an object. - * For `mongodb`, the `dbName` setting was renamed to `database` (but `dbName` - still works for backwards compatibility) and is now optional (if unset, the - database name in `url` is used). -* `/admin/settings` now honors the `--settings` command-line argument. -* Fixed "Author *X* tried to submit changes as author *Y*" detection. -* Error message display improvements. -* Simplified pad reload after importing an `.etherpad` file. - -#### For plugin authors - -* `clientVars` was added to the context for the `postAceInit` client-side hook. - Plugins should use this instead of the `clientVars` global variable. -* New `userJoin` server-side hook. -* The `userLeave` server-side hook has a new `socket` context property. -* The `helper.aNewPad()` function (accessible to client-side tests) now - accepts hook functions to inject when opening a pad. This can be used to - test any new client-side hooks your plugin provides. -* Chat improvements: - * The `chatNewMessage` client-side hook context has new properties: - * `message`: Provides access to the raw message object so that plugins can - see the original unprocessed message text and any added metadata. - * `rendered`: Allows plugins to completely override how the message is - rendered in the UI. - * New `chatSendMessage` client-side hook that enables plugins to process the - text before sending it to the server or augment the message object with - custom metadata. - * New `chatNewMessage` server-side hook to process new chat messages before - they are saved to the database and relayed to users. -* Readability improvements to browser-side error stack traces. -* Added support for socket.io message acknowledgments. - -# 1.8.14 - -### Security fixes - -* Fixed a persistent XSS vulnerability in the Chat component. In case you can't - update to 1.8.14 directly, we strongly recommend to cherry-pick - a7968115581e20ef47a533e030f59f830486bdfa. Thanks to sonarsource for the - professional disclosure. - -### Compatibility changes - -* Node.js v12.13.0 or later is now required. -* The `favicon` setting is now interpreted as a pathname to a favicon file, not - a URL. Please see the documentation comment in `settings.json.template`. -* The undocumented `faviconPad` and `faviconTimeslider` settings have been - removed. -* MySQL/MariaDB now uses connection pooling, which means you will see up to 10 - connections to the MySQL/MariaDB server (by default) instead of 1. This might - cause Etherpad to crash with a "ER_CON_COUNT_ERROR: Too many connections" - error if your server is configured with a low connection limit. -* Changes to environment variable substitution in `settings.json` (see the - documentation comments in `settings.json.template` for details): - * An environment variable set to the string "null" now becomes `null` instead - of the string "null". Similarly, if the environment variable is unset and - the default value is "null" (e.g., `"${UNSET_VAR:null}"`), the value now - becomes `null` instead of the string "null". It is no longer possible to - produce the string "null" via environment variable substitution. - * An environment variable set to the string "undefined" now causes the setting - to be removed instead of set to the string "undefined". Similarly, if the - environment variable is unset and the default value is "undefined" (e.g., - `"${UNSET_VAR:undefined}"`), the setting is now removed instead of set to - the string "undefined". It is no longer possible to produce the string - "undefined" via environment variable substitution. - * Support for unset variables without a default value is now deprecated. - Please change all instances of `"${FOO}"` in your `settings.json` to - `${FOO:null}` to keep the current behavior. - * The `DB_*` variable substitutions in `settings.json.docker` that previously - defaulted to `null` now default to "undefined". -* Calling `next` without argument when using `Changeset.opIterator` does always - return a new Op. See b9753dcc7156d8471a5aa5b6c9b85af47f630aa8 for details. - -### Notable enhancements and fixes - -* MySQL/MariaDB now uses connection pooling, which should improve stability and - reduce latency. -* Bulk database writes are now retried individually on write failure. -* Minify: Avoid crash due to unhandled Promise rejection if stat fails. -* padIds are now included in /socket.io query string, e.g. - `https://video.etherpad.com/socket.io/?padId=AWESOME&EIO=3&transport=websocket&t=...&sid=...`. - This is useful for directing pads to separate socket.io nodes. -* - - diff --git a/admin/package.json b/admin/package.json deleted file mode 100644 index cef9381a5..000000000 --- a/admin/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "admin", - "private": true, - "version": "2.3.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "build-copy": "tsc && vite build --outDir ../src/templates/admin --emptyOutDir", - "preview": "vite preview" - }, - "dependencies": { - "@radix-ui/react-switch": "^1.1.4" - }, - "devDependencies": { - "@radix-ui/react-dialog": "^1.1.11", - "@radix-ui/react-toast": "^1.2.11", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.3", - "@typescript-eslint/eslint-plugin": "^8.31.1", - "@typescript-eslint/parser": "^8.31.1", - "@vitejs/plugin-react-swc": "^3.9.0", - "eslint": "^9.25.1", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "i18next": "^25.0.2", - "i18next-browser-languagedetector": "^8.1.0", - "lucide-react": "^0.503.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-hook-form": "^7.56.1", - "react-i18next": "^15.5.1", - "react-router-dom": "^7.5.3", - "socket.io-client": "^4.8.1", - "typescript": "^5.8.2", - "vite": "^6.3.4", - "vite-plugin-static-copy": "^2.3.1", - "vite-plugin-svgr": "^4.3.0", - "zustand": "^5.0.3" - } -} diff --git a/admin/public/Karla-Bold.ttf b/admin/public/Karla-Bold.ttf deleted file mode 100644 index 2348e0072..000000000 Binary files a/admin/public/Karla-Bold.ttf and /dev/null differ diff --git a/admin/public/Karla-BoldItalic.ttf b/admin/public/Karla-BoldItalic.ttf deleted file mode 100644 index 3c0e045ec..000000000 Binary files a/admin/public/Karla-BoldItalic.ttf and /dev/null differ diff --git a/admin/public/Karla-ExtraBold.ttf b/admin/public/Karla-ExtraBold.ttf deleted file mode 100644 index f18471195..000000000 Binary files a/admin/public/Karla-ExtraBold.ttf and /dev/null differ diff --git a/admin/public/Karla-ExtraBoldItalic.ttf b/admin/public/Karla-ExtraBoldItalic.ttf deleted file mode 100644 index 3799659c0..000000000 Binary files a/admin/public/Karla-ExtraBoldItalic.ttf and /dev/null differ diff --git a/admin/public/Karla-ExtraLight.ttf b/admin/public/Karla-ExtraLight.ttf deleted file mode 100644 index 0f8642c02..000000000 Binary files a/admin/public/Karla-ExtraLight.ttf and /dev/null differ diff --git a/admin/public/Karla-ExtraLightItalic.ttf b/admin/public/Karla-ExtraLightItalic.ttf deleted file mode 100644 index bb328e175..000000000 Binary files a/admin/public/Karla-ExtraLightItalic.ttf and /dev/null differ diff --git a/admin/public/Karla-Italic.ttf b/admin/public/Karla-Italic.ttf deleted file mode 100644 index 1853cbe4e..000000000 Binary files a/admin/public/Karla-Italic.ttf and /dev/null differ diff --git a/admin/public/Karla-Light.ttf b/admin/public/Karla-Light.ttf deleted file mode 100644 index 46457ece7..000000000 Binary files a/admin/public/Karla-Light.ttf and /dev/null differ diff --git a/admin/public/Karla-LightItalic.ttf b/admin/public/Karla-LightItalic.ttf deleted file mode 100644 index 3b0f01ff1..000000000 Binary files a/admin/public/Karla-LightItalic.ttf and /dev/null differ diff --git a/admin/public/Karla-Medium.ttf b/admin/public/Karla-Medium.ttf deleted file mode 100644 index 9066b49c4..000000000 Binary files a/admin/public/Karla-Medium.ttf and /dev/null differ diff --git a/admin/public/Karla-MediumItalic.ttf b/admin/public/Karla-MediumItalic.ttf deleted file mode 100644 index ea9535355..000000000 Binary files a/admin/public/Karla-MediumItalic.ttf and /dev/null differ diff --git a/admin/public/Karla-Regular.ttf b/admin/public/Karla-Regular.ttf deleted file mode 100644 index c164d0047..000000000 Binary files a/admin/public/Karla-Regular.ttf and /dev/null differ diff --git a/admin/public/Karla-SemiBold.ttf b/admin/public/Karla-SemiBold.ttf deleted file mode 100644 index 82e0c5abf..000000000 Binary files a/admin/public/Karla-SemiBold.ttf and /dev/null differ diff --git a/admin/public/Karla-SemiBoldItalic.ttf b/admin/public/Karla-SemiBoldItalic.ttf deleted file mode 100644 index 77c19ef69..000000000 Binary files a/admin/public/Karla-SemiBoldItalic.ttf and /dev/null differ diff --git a/admin/public/ep_admin_pads/ar.json b/admin/public/ep_admin_pads/ar.json deleted file mode 100644 index 746946edf..000000000 --- a/admin/public/ep_admin_pads/ar.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Meno25", - "محمد أحمد عبد الفتاح" - ] - }, - "ep_adminpads2_action": "فعل", - "ep_adminpads2_autoupdate-label": "التحديث التلقائي على تغييرات الوسادة", - "ep_adminpads2_autoupdate.title": "لتمكين أو تعطيل التحديثات التلقائية للاستعلام الحالي.", - "ep_adminpads2_confirm": "هل تريد حقًا حذف الوسادة {{padID}}؟", - "ep_adminpads2_delete.value": "حذف", - "ep_adminpads2_last-edited": "آخر تعديل", - "ep_adminpads2_loading": "جارٍ التحميل...", - "ep_adminpads2_manage-pads": "إدارة الفوط", - "ep_adminpads2_no-results": "لا توجد نتائج.", - "ep_adminpads2_pad-user-count": "عدد المستخدمين الوسادة", - "ep_adminpads2_padname": "بادنام", - "ep_adminpads2_search-box.placeholder": "مصطلح البحث", - "ep_adminpads2_search-button.value": "بحث", - "ep_adminpads2_search-done": "اكتمل البحث", - "ep_adminpads2_search-error-explanation": "واجه الخادم خطأً أثناء البحث عن منصات:", - "ep_adminpads2_search-error-title": "فشل في الحصول على قائمة الوسادة", - "ep_adminpads2_search-heading": "ابحث عن الفوط", - "ep_adminpads2_title": "إدارة الوسادة", - "ep_adminpads2_unknown-error": "خطأ غير معروف", - "ep_adminpads2_unknown-status": "حالة غير معروفة" -} diff --git a/admin/public/ep_admin_pads/bn.json b/admin/public/ep_admin_pads/bn.json deleted file mode 100644 index 0048b52bb..000000000 --- a/admin/public/ep_admin_pads/bn.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@metadata": { - "authors": [ - "আজিজ", - "আফতাবুজ্জামান" - ] - }, - "ep_adminpads2_action": "কার্য", - "ep_adminpads2_delete.value": "মুছে ফেলুন", - "ep_adminpads2_last-edited": "সর্বশেষ সম্পাদিত", - "ep_adminpads2_loading": "লোড হচ্ছে...", - "ep_adminpads2_manage-pads": "প্যাড পরিচালনা করুন", - "ep_adminpads2_no-results": "ফলাফল নেই", - "ep_adminpads2_padname": "প্যাডের নাম", - "ep_adminpads2_search-button.value": "অনুসন্ধান", - "ep_adminpads2_search-done": "অনুসন্ধান সম্পূর্ণ", - "ep_adminpads2_search-error-explanation": "প্যাড অনুসন্ধান করার সময় সার্ভার একটি ত্রুটির সম্মুখীন হয়েছে:", - "ep_adminpads2_search-error-title": "প্যাডের তালিকা পেতে ব্যর্থ", - "ep_adminpads2_search-heading": "প্যাড অনুসন্ধান করুন", - "ep_adminpads2_title": "প্যাড প্রশাসন", - "ep_adminpads2_unknown-error": "অজানা ত্রুটি", - "ep_adminpads2_unknown-status": "অজানা অবস্থা" -} diff --git a/admin/public/ep_admin_pads/ca.json b/admin/public/ep_admin_pads/ca.json deleted file mode 100644 index 1d4e34216..000000000 --- a/admin/public/ep_admin_pads/ca.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Mguix" - ] - }, - "ep_adminpads2_action": "Acció", - "ep_adminpads2_autoupdate-label": "Actualització automàtica en cas de canvis de pad", - "ep_adminpads2_autoupdate.title": "Activa o desactiva les actualitzacions automàtiques per a la consulta actual.", - "ep_adminpads2_confirm": "Esteu segur que voleu suprimir el pad {{padID}}?", - "ep_adminpads2_delete.value": "Esborrar", - "ep_adminpads2_last-edited": "Darrera modificació", - "ep_adminpads2_loading": "S’està carregant…", - "ep_adminpads2_manage-pads": "Gestiona els pads", - "ep_adminpads2_no-results": "No hi ha cap resultat", - "ep_adminpads2_pad-user-count": "Nombre d'usuaris de pads", - "ep_adminpads2_padname": "Nom del pad", - "ep_adminpads2_search-box.placeholder": "Terme de cerca", - "ep_adminpads2_search-button.value": "Cercar", - "ep_adminpads2_search-done": "Cerca completa", - "ep_adminpads2_search-error-explanation": "El servidor ha trobat un error mentre buscava pads:", - "ep_adminpads2_search-error-title": "No s'ha pogut obtenir la llista del pad", - "ep_adminpads2_search-heading": "Cerca pads", - "ep_adminpads2_title": "Administració del pad", - "ep_adminpads2_unknown-error": "Error desconegut", - "ep_adminpads2_unknown-status": "Estat desconegut" -} diff --git a/admin/public/ep_admin_pads/cs.json b/admin/public/ep_admin_pads/cs.json deleted file mode 100644 index 19e92894d..000000000 --- a/admin/public/ep_admin_pads/cs.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Spotter" - ] - }, - "ep_adminpads2_action": "Akce", - "ep_adminpads2_autoupdate-label": "Automatická aktualizace změn Padu", - "ep_adminpads2_autoupdate.title": "Povolí nebo zakáže automatické aktualizace pro aktuální dotaz.", - "ep_adminpads2_confirm": "Opravdu chcete odstranit pad {{padID}}?", - "ep_adminpads2_delete.value": "Smazat", - "ep_adminpads2_last-edited": "Naposledy upraveno", - "ep_adminpads2_loading": "Načítání…", - "ep_adminpads2_manage-pads": "Spravovat pady", - "ep_adminpads2_no-results": "Žádné výsledky", - "ep_adminpads2_pad-user-count": "Počet uživatelů padu", - "ep_adminpads2_padname": "Název padu", - "ep_adminpads2_search-box.placeholder": "Hledaný výraz", - "ep_adminpads2_search-button.value": "Hledat", - "ep_adminpads2_search-done": "Hledání dokončeno", - "ep_adminpads2_search-error-explanation": "Při hledání padů došlo k chybě serveru:", - "ep_adminpads2_search-error-title": "Seznam padů se nepodařilo získat", - "ep_adminpads2_search-heading": "Hledat pady", - "ep_adminpads2_title": "Správa Padu", - "ep_adminpads2_unknown-error": "Neznámá chyba", - "ep_adminpads2_unknown-status": "Neznámý stav" -} diff --git a/admin/public/ep_admin_pads/cy.json b/admin/public/ep_admin_pads/cy.json deleted file mode 100644 index 02546da90..000000000 --- a/admin/public/ep_admin_pads/cy.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Robin Owain" - ] - }, - "ep_adminpads2_action": "Gweithred", - "ep_adminpads2_autoupdate-label": "Diweddaru newidiadau pad yn otomatig", - "ep_adminpads2_autoupdate.title": "Galluogi neu analluogi diweddaru'r ymholiad cyfredol.", - "ep_adminpads2_confirm": "Siwr eich bod am ddileu'r pad {{padID}}?", - "ep_adminpads2_delete.value": "Dileu", - "ep_adminpads2_last-edited": "Golygwyd ddiwethaf", - "ep_adminpads2_loading": "Wrthi'n llwytho...", - "ep_adminpads2_manage-pads": "Rheoli'r padiau", - "ep_adminpads2_no-results": "Dim canlyniad", - "ep_adminpads2_pad-user-count": "Cyfri defnyddiwr pad", - "ep_adminpads2_padname": "Enwpad", - "ep_adminpads2_search-box.placeholder": "Term chwilio", - "ep_adminpads2_search-button.value": "Chwilio", - "ep_adminpads2_search-done": "Wedi gorffen", - "ep_adminpads2_search-error-explanation": "Nam ar y gweinydd wrth chwilio'r padiau:", - "ep_adminpads2_search-error-title": "Methwyd a chael y rhestr pad", - "ep_adminpads2_search-heading": "Chwilio am badiau", - "ep_adminpads2_title": "Gweinyddiaeth y pad", - "ep_adminpads2_unknown-error": "Nam o ryw fath", - "ep_adminpads2_unknown-status": "Statws anhysbys" -} diff --git a/admin/public/ep_admin_pads/da.json b/admin/public/ep_admin_pads/da.json deleted file mode 100644 index a5303b9cb..000000000 --- a/admin/public/ep_admin_pads/da.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Saederup92" - ] - }, - "ep_adminpads2_action": "Handling", - "ep_adminpads2_delete.value": "Slet", - "ep_adminpads2_last-edited": "Sidst redigeret", - "ep_adminpads2_loading": "Indlæser...", - "ep_adminpads2_no-results": "Ingen resultater", - "ep_adminpads2_unknown-error": "Ukendt fejl", - "ep_adminpads2_unknown-status": "Ukendt status" -} diff --git a/admin/public/ep_admin_pads/de.json b/admin/public/ep_admin_pads/de.json deleted file mode 100644 index 67dd73ddf..000000000 --- a/admin/public/ep_admin_pads/de.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Brettchenweber", - "Justman10000", - "Lorisobi", - "SamTV", - "Umlaut", - "Zunkelty" - ] - }, - "ep_adminpads2_action": "Aktion", - "ep_adminpads2_autoupdate-label": "Automatisch bei Pad-Änderungen updaten", - "ep_adminpads2_autoupdate.title": "Aktiviert oder deaktiviert automatische Aktualisierungen für die aktuelle Abfrage.", - "ep_adminpads2_confirm": "Willst du das Pad {{padID}} wirklich löschen?", - "ep_adminpads2_delete.value": "Löschen", - "ep_adminpads2_cleanup": "Historie aufräumen", - "ep_adminpads2_last-edited": "Zuletzt bearbeitet", - "ep_adminpads2_loading": "Lädt...", - "ep_adminpads2_manage-pads": "Pads verwalten", - "ep_adminpads2_no-results": "Keine Ergebnisse", - "ep_adminpads2_pad-user-count": "Nutzerzahl des Pads", - "ep_adminpads2_padname": "Padname", - "ep_adminpads2_search-box.placeholder": "Suchbegriff", - "ep_adminpads2_search-button.value": "Suche", - "ep_adminpads2_search-done": "Suche vollendet", - "ep_adminpads2_search-error-explanation": "Der Server ist bei der Suche nach Pads auf einen Fehler gestoßen:", - "ep_adminpads2_search-error-title": "Pad-Liste konnte nicht abgerufen werden", - "ep_adminpads2_search-heading": "Nach Pads suchen", - "ep_adminpads2_title": "Pad-Verwaltung", - "ep_adminpads2_unknown-error": "Unbekannter Fehler", - "ep_adminpads2_unknown-status": "Unbekannter Status" -} diff --git a/admin/public/ep_admin_pads/diq.json b/admin/public/ep_admin_pads/diq.json deleted file mode 100644 index 983680965..000000000 --- a/admin/public/ep_admin_pads/diq.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "@metadata": { - "authors": [ - "1917 Ekim Devrimi", - "Mirzali" - ] - }, - "ep_adminpads2_action": "Hereketi", - "ep_adminpads2_autoupdate-label": "Vurnayışanê pedi otomatik rocane kerê", - "ep_adminpads2_autoupdate.title": "Persê mewcudi rê rocaneyışanê otomatika aktiv ke ya zi dewrê ra vecê", - "ep_adminpads2_confirm": "Şıma qayılê pedê {{padID}} bıesternê?", - "ep_adminpads2_delete.value": "Bestere", - "ep_adminpads2_last-edited": "Vurnayışo peyên", - "ep_adminpads2_loading": "Bar beno...", - "ep_adminpads2_manage-pads": "Pedan idare kerê", - "ep_adminpads2_no-results": "Netice çıniyo", - "ep_adminpads2_pad-user-count": "Amarê karberanê pedi", - "ep_adminpads2_padname": "Padname", - "ep_adminpads2_search-box.placeholder": "termê cıgêrayış", - "ep_adminpads2_search-button.value": "Cı geyre", - "ep_adminpads2_search-done": "Cıgeyrayışi temam", - "ep_adminpads2_search-error-explanation": "Server cıgeyrayışê pedan de yew xetaya raşt ame", - "ep_adminpads2_search-error-title": "Lista pedi nêgêriye", - "ep_adminpads2_search-heading": "Pedan cıgeyrayış", - "ep_adminpads2_title": "İdarey pedi", - "ep_adminpads2_unknown-error": "Xetaya nêzanıtiye", - "ep_adminpads2_unknown-status": "Weziyeto nêzanaye" -} diff --git a/admin/public/ep_admin_pads/dsb.json b/admin/public/ep_admin_pads/dsb.json deleted file mode 100644 index 363732a20..000000000 --- a/admin/public/ep_admin_pads/dsb.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Michawiki" - ] - }, - "ep_adminpads2_action": "Akcija", - "ep_adminpads2_autoupdate-label": "Pśi změnach na zapisniku awtomatiski aktualizěrowaś", - "ep_adminpads2_autoupdate.title": "Zmóžnja abo znjemóžnja awtomatiske aktualizacije za aktualne wótpšašowanje.", - "ep_adminpads2_confirm": "Cośo napšawdu zapisnik {{padID}} lašowaś?", - "ep_adminpads2_delete.value": "Lašowaś", - "ep_adminpads2_last-edited": "Slědna změna", - "ep_adminpads2_loading": "Zacytujo se...", - "ep_adminpads2_manage-pads": "Zapisniki zastojaś", - "ep_adminpads2_no-results": "Žedne wuslědki", - "ep_adminpads2_pad-user-count": "Licba wužywarjow zapisnika", - "ep_adminpads2_padname": "Mě zapisnika", - "ep_adminpads2_search-box.placeholder": "Pytańske zapśimjeśe", - "ep_adminpads2_search-button.value": "Pytaś", - "ep_adminpads2_search-done": "Pytanje dokóńcone", - "ep_adminpads2_search-error-explanation": "Serwer jo starcył na zmólku, mjaztym až jo pytał za zapisnikami:", - "ep_adminpads2_search-error-title": "Lisćina zapisnikow njedajo se wobstaraś", - "ep_adminpads2_search-heading": "Za zapisnikami pytaś", - "ep_adminpads2_title": "Zapisnikowa administracija", - "ep_adminpads2_unknown-error": "Njeznata zmólka", - "ep_adminpads2_unknown-status": "Njeznaty status" -} diff --git a/admin/public/ep_admin_pads/el.json b/admin/public/ep_admin_pads/el.json deleted file mode 100644 index 77b6af3dd..000000000 --- a/admin/public/ep_admin_pads/el.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Norhorn" - ] - }, - "ep_adminpads2_delete.value": "Διαγραφή", - "ep_adminpads2_last-edited": "Τελευταία απεξεργασία", - "ep_adminpads2_loading": "Φόρτωση…", - "ep_adminpads2_no-results": "Κανένα αποτέλεσμα", - "ep_adminpads2_search-box.placeholder": "Αναζήτηση όρων", - "ep_adminpads2_search-button.value": "Αναζήτηση", - "ep_adminpads2_search-done": "Ολοκλήρωση αναζήτησης", - "ep_adminpads2_unknown-error": "Άγνωστο σφάλμα", - "ep_adminpads2_unknown-status": "Άγνωστη κατάσταση" -} diff --git a/admin/public/ep_admin_pads/en.json b/admin/public/ep_admin_pads/en.json deleted file mode 100644 index 76354c640..000000000 --- a/admin/public/ep_admin_pads/en.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "ep_adminpads2_action": "Action", - "ep_adminpads2_autoupdate-label": "Auto-update on pad changes", - "ep_adminpads2_autoupdate.title": "Enables or disables automatic updates for the current query.", - "ep_adminpads2_confirm": "Do you really want to delete the pad {{padID}}?", - "ep_adminpads2_delete.value": "Delete", - "ep_adminpads2_cleanup": "Cleanup revisions", - "ep_adminpads2_last-edited": "Last edited", - "ep_adminpads2_loading": "Loading…", - "ep_adminpads2_manage-pads": "Manage pads", - "ep_adminpads2_no-results": "No results", - "ep_adminpads2_pad-user-count": "Pad user count", - "ep_adminpads2_padname": "Padname", - "ep_adminpads2_search-box.placeholder": "Search term", - "ep_adminpads2_search-button.value": "Search", - "ep_adminpads2_search-done": "Search complete", - "ep_adminpads2_search-error-explanation": "The server encountered an error while searching for pads:", - "ep_adminpads2_search-error-title": "Failed to get pad list", - "ep_adminpads2_search-heading": "Search for pads", - "ep_adminpads2_title": "Pad administration", - "ep_adminpads2_unknown-error": "Unknown error", - "ep_adminpads2_unknown-status": "Unknown status" -} diff --git a/admin/public/ep_admin_pads/eu.json b/admin/public/ep_admin_pads/eu.json deleted file mode 100644 index 71d9dfe79..000000000 --- a/admin/public/ep_admin_pads/eu.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Izendegi" - ] - }, - "ep_adminpads2_action": "Ekintza", - "ep_adminpads2_autoupdate-label": "Automatikoki eguneratu pad-aren aldaketak daudenean", - "ep_adminpads2_autoupdate.title": "Oraingo kontsultarako eguneratze automatikoak gaitu edo desgaitzen du.", - "ep_adminpads2_confirm": "Ziur zaude {{padID}} pad-a ezabatu nahi duzula?", - "ep_adminpads2_delete.value": "Ezabatu", - "ep_adminpads2_last-edited": "Azkenengoz editatua", - "ep_adminpads2_loading": "Kargatzen...", - "ep_adminpads2_manage-pads": "Kudeatu pad-ak", - "ep_adminpads2_no-results": "Emaitzarik ez", - "ep_adminpads2_pad-user-count": "Pad-erabiltzaile kopurua", - "ep_adminpads2_padname": "Pad-izena", - "ep_adminpads2_search-box.placeholder": "Bilaketa testua", - "ep_adminpads2_search-button.value": "Bilatu", - "ep_adminpads2_search-done": "Bilaketa osatu da", - "ep_adminpads2_search-error-explanation": "Zerbitzariak errore bat izan du pad-ak bilatzean:", - "ep_adminpads2_search-error-title": "Pad-zerrenda eskuratzeak huts egin du", - "ep_adminpads2_search-heading": "Bilatu pad-ak", - "ep_adminpads2_title": "Pad-en kudeaketa", - "ep_adminpads2_unknown-error": "Errore ezezaguna", - "ep_adminpads2_unknown-status": "Egoera ezezaguna" -} diff --git a/admin/public/ep_admin_pads/ff.json b/admin/public/ep_admin_pads/ff.json deleted file mode 100644 index 8cb5aea99..000000000 --- a/admin/public/ep_admin_pads/ff.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Ibrahima Malal Sarr" - ] - }, - "ep_adminpads2_action": "Baɗal", - "ep_adminpads2_autoupdate-label": "Hesɗitin e jaajol tuma baylagol faɗo", - "ep_adminpads2_autoupdate.title": "Hurminat walla daaƴa kesɗitine jaaje wonannde ɗaɓɓitannde wonaande.", - "ep_adminpads2_confirm": "Aɗa yiɗi e jaati momtude faɗo {{padID}}?", - "ep_adminpads2_delete.value": "Momtu", - "ep_adminpads2_last-edited": "Taƴtaa sakket", - "ep_adminpads2_loading": "Nana loowa…", - "ep_adminpads2_manage-pads": "Toppito paɗe", - "ep_adminpads2_no-results": "Alaa njaltudi", - "ep_adminpads2_pad-user-count": "Limoore huutorɓe faɗo", - "ep_adminpads2_padname": "Innde faɗo", - "ep_adminpads2_search-box.placeholder": "Helmere njiilaw", - "ep_adminpads2_search-button.value": "Yiylo", - "ep_adminpads2_search-done": "Njiylaw timmii", - "ep_adminpads2_search-error-explanation": "Sarworde ndee hawrii e juumre tuma nde yiylotoo faɗo:", - "ep_adminpads2_search-error-title": "Horiima heɓde doggol paɗe", - "ep_adminpads2_search-heading": "Yiylo paɗe", - "ep_adminpads2_title": "Yiylorde paɗe", - "ep_adminpads2_unknown-error": "Juumre nde anndaaka", - "ep_adminpads2_unknown-status": "Ngonka ka anndaaka" -} diff --git a/admin/public/ep_admin_pads/fi.json b/admin/public/ep_admin_pads/fi.json deleted file mode 100644 index 708b2bef8..000000000 --- a/admin/public/ep_admin_pads/fi.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Artnay", - "Kyykaarme", - "MITO", - "Maantietäjä", - "Yupik" - ] - }, - "ep_adminpads2_action": "Toiminto", - "ep_adminpads2_delete.value": "Poista", - "ep_adminpads2_last-edited": "Viimeksi muokattu", - "ep_adminpads2_loading": "Ladataan...", - "ep_adminpads2_manage-pads": "Hallitse muistioita", - "ep_adminpads2_no-results": "Ei tuloksia", - "ep_adminpads2_pad-user-count": "Pad-käyttäjien määrä", - "ep_adminpads2_padname": "Muistion nimi", - "ep_adminpads2_search-box.placeholder": "Haettava teksti", - "ep_adminpads2_search-button.value": "Etsi", - "ep_adminpads2_search-done": "Haku valmis", - "ep_adminpads2_search-error-explanation": "Palvelimessa tapahtui virhe etsiessään muistioita:", - "ep_adminpads2_search-error-title": "Pad-luettelon hakeminen epäonnistui", - "ep_adminpads2_search-heading": "Etsi sisältöä", - "ep_adminpads2_unknown-error": "Tuntematon virhe", - "ep_adminpads2_unknown-status": "Tuntematon tila" -} diff --git a/admin/public/ep_admin_pads/fr.json b/admin/public/ep_admin_pads/fr.json deleted file mode 100644 index e6c8a8703..000000000 --- a/admin/public/ep_admin_pads/fr.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Verdy p" - ] - }, - "ep_adminpads2_action": "Action", - "ep_adminpads2_autoupdate-label": "Mise à jour automatique en cas de changements du bloc-notes", - "ep_adminpads2_autoupdate.title": "Active ou désactive les mises à jour automatiques pour la requête actuelle.", - "ep_adminpads2_confirm": "Voulez-vous vraiment supprimer le bloc-notes {{padID}} ?", - "ep_adminpads2_delete.value": "Supprimer", - "ep_adminpads2_last-edited": "Dernière modification", - "ep_adminpads2_loading": "Chargement en cours...", - "ep_adminpads2_manage-pads": "Gérer les bloc-notes", - "ep_adminpads2_no-results": "Aucun résultat", - "ep_adminpads2_pad-user-count": "Nombre d’utilisateurs du bloc-notes", - "ep_adminpads2_padname": "Nom du bloc-notes", - "ep_adminpads2_search-box.placeholder": "Terme de recherche", - "ep_adminpads2_search-button.value": "Rechercher", - "ep_adminpads2_search-done": "Recherche terminée", - "ep_adminpads2_search-error-explanation": "Le serveur a rencontré une erreur en cherchant des blocs-notes :", - "ep_adminpads2_search-error-title": "Échec d’obtention de la liste de blocs-notes", - "ep_adminpads2_search-heading": "Rechercher des blocs-notes", - "ep_adminpads2_title": "Administration du bloc-notes", - "ep_adminpads2_unknown-error": "Erreur inconnue", - "ep_adminpads2_unknown-status": "État inconnu" -} diff --git a/admin/public/ep_admin_pads/gl.json b/admin/public/ep_admin_pads/gl.json deleted file mode 100644 index 5e6b66549..000000000 --- a/admin/public/ep_admin_pads/gl.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Ghose" - ] - }, - "ep_adminpads2_action": "Accións", - "ep_adminpads2_autoupdate-label": "Actualización automática dos cambios", - "ep_adminpads2_autoupdate.title": "Activa ou desactiva as actualizacións automáticas para a consulta actual.", - "ep_adminpads2_confirm": "Tes a certeza de querer eliminar o pad {{padID}}?", - "ep_adminpads2_delete.value": "Eliminar", - "ep_adminpads2_last-edited": "Última edición", - "ep_adminpads2_loading": "Cargando…", - "ep_adminpads2_manage-pads": "Xestionar pads", - "ep_adminpads2_no-results": "Sen resultados", - "ep_adminpads2_pad-user-count": "Usuarias neste pad", - "ep_adminpads2_padname": "Nome do pad", - "ep_adminpads2_search-box.placeholder": "Buscar termo", - "ep_adminpads2_search-button.value": "Buscar", - "ep_adminpads2_search-done": "Busca completa", - "ep_adminpads2_search-error-explanation": "O servidor atopou un fallo cando buscaba pads:", - "ep_adminpads2_search-error-title": "Non se obtivo a lista de pads", - "ep_adminpads2_search-heading": "Buscar pads", - "ep_adminpads2_title": "Administración do pad", - "ep_adminpads2_unknown-error": "Erro descoñecido", - "ep_adminpads2_unknown-status": "Estado descoñecido" -} diff --git a/admin/public/ep_admin_pads/he.json b/admin/public/ep_admin_pads/he.json deleted file mode 100644 index 8b506946b..000000000 --- a/admin/public/ep_admin_pads/he.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "YaronSh" - ] - }, - "ep_adminpads2_action": "פעולה", - "ep_adminpads2_autoupdate-label": "לעדכן אוטומטית כשהמחברת נערכת", - "ep_adminpads2_autoupdate.title": "הפעלה או השבתה של עדכונים אוטומטיים לשאילתה הנוכחית.", - "ep_adminpads2_confirm": "למחוק את המחברת {{padID}}?", - "ep_adminpads2_delete.value": "מחיקה", - "ep_adminpads2_last-edited": "עריכה אחרונה", - "ep_adminpads2_loading": "בטעינה…", - "ep_adminpads2_manage-pads": "ניהול מחברות", - "ep_adminpads2_no-results": "אין תוצאות", - "ep_adminpads2_pad-user-count": "ספירת משתמשים במחברת", - "ep_adminpads2_padname": "שם המחברת", - "ep_adminpads2_search-box.placeholder": "הביטוי לחיפוש", - "ep_adminpads2_search-button.value": "חיפוש", - "ep_adminpads2_search-done": "החיפוש הושלם", - "ep_adminpads2_search-error-explanation": "השרת נתקל בשגיאה בעת חיפוש מחברות:", - "ep_adminpads2_search-error-title": "קבלת רשימת המחברות נכשלה", - "ep_adminpads2_search-heading": "חיפוש אחר מחברות", - "ep_adminpads2_title": "ניהול מחברות", - "ep_adminpads2_unknown-error": "שגיאה בלתי־ידועה", - "ep_adminpads2_unknown-status": "מצב לא ידוע" -} diff --git a/admin/public/ep_admin_pads/hsb.json b/admin/public/ep_admin_pads/hsb.json deleted file mode 100644 index a6c29611f..000000000 --- a/admin/public/ep_admin_pads/hsb.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Michawiki" - ] - }, - "ep_adminpads2_action": "Akcija", - "ep_adminpads2_autoupdate-label": "Při změnach na zapisniku awtomatisce aktualizować", - "ep_adminpads2_autoupdate.title": "Zmóžnja abo znjemóžnja awtomatiske aktualizacije za aktualne wotprašowanje.", - "ep_adminpads2_confirm": "Chceće woprawdźe zapisnik {{padID}} zhašeć?", - "ep_adminpads2_delete.value": "Zhašeć", - "ep_adminpads2_last-edited": "Poslednja změna", - "ep_adminpads2_loading": "Začituje so...", - "ep_adminpads2_manage-pads": "Zapisniki rjadować", - "ep_adminpads2_no-results": "Žane wuslědki.", - "ep_adminpads2_pad-user-count": "Ličba wužiwarjow zapisnika", - "ep_adminpads2_padname": "Mjeno zapisnika", - "ep_adminpads2_search-box.placeholder": "Pytanske zapřijeće", - "ep_adminpads2_search-button.value": "Pytać", - "ep_adminpads2_search-done": "Pytanje dokónčene", - "ep_adminpads2_search-error-explanation": "Serwer je na zmylk storčił, mjeztym zo je za zapisnikami pytał:", - "ep_adminpads2_search-error-title": "Lisćina zapisnikow njeda so wobstarać", - "ep_adminpads2_search-heading": "Za zapisnikami pytać", - "ep_adminpads2_title": "Zapisnikowa administracija", - "ep_adminpads2_unknown-error": "Njeznaty zmylk", - "ep_adminpads2_unknown-status": "Njeznaty status" -} diff --git a/admin/public/ep_admin_pads/hu.json b/admin/public/ep_admin_pads/hu.json deleted file mode 100644 index 9210761bc..000000000 --- a/admin/public/ep_admin_pads/hu.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "@metadata": { - "authors": [] - }, - "ep_adminpads2_action": "Művelet", - "ep_adminpads2_autoupdate-label": "Változáskor jegyzetfüzet önműködő frissítése", - "ep_adminpads2_autoupdate.title": "Önműködő frissítése az jelenlegi lekérdezéshez be- vagy kikapcsolása.", - "ep_adminpads2_confirm": "Biztosan törölni szeretné a(z) {{padID}} jegyzetfüzetet?", - "ep_adminpads2_delete.value": "Törlés", - "ep_adminpads2_last-edited": "Utoljára szerkesztve", - "ep_adminpads2_loading": "Betöltés folyamatban…", - "ep_adminpads2_manage-pads": "Jegyzetfüzetek kezelése", - "ep_adminpads2_no-results": "Nincs találat", - "ep_adminpads2_pad-user-count": "Jegyzetfüzet felhasználók száma", - "ep_adminpads2_padname": "Jegyzetfüzet név", - "ep_adminpads2_search-box.placeholder": "Keresési kifejezés", - "ep_adminpads2_search-button.value": "Keresés", - "ep_adminpads2_search-done": "Keresés befejezve", - "ep_adminpads2_search-error-explanation": "A kiszolgáló hibát észlelt a jegyzetfüzetek keresésekor:", - "ep_adminpads2_search-error-title": "Nem sikerült lekérni a jegyzetfüzet listát", - "ep_adminpads2_search-heading": "Jegyzetfüzetek keresése", - "ep_adminpads2_title": "Jegyzetfüzet felügyelete", - "ep_adminpads2_unknown-error": "Ismeretlen hiba", - "ep_adminpads2_unknown-status": "Ismeretlen állapot" -} diff --git a/admin/public/ep_admin_pads/ia.json b/admin/public/ep_admin_pads/ia.json deleted file mode 100644 index f0e00e5ca..000000000 --- a/admin/public/ep_admin_pads/ia.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "McDutchie" - ] - }, - "ep_adminpads2_action": "Action", - "ep_adminpads2_autoupdate-label": "Actualisar automaticamente le pad in caso de cambiamentos", - "ep_adminpads2_autoupdate.title": "Activa o disactiva le actualisationes automatic pro le consulta actual.", - "ep_adminpads2_confirm": "Es tu secur de voler deler le pad {{padID}}?", - "ep_adminpads2_delete.value": "Deler", - "ep_adminpads2_last-edited": "Ultime modification", - "ep_adminpads2_loading": "Cargamento in curso…", - "ep_adminpads2_manage-pads": "Gerer pads", - "ep_adminpads2_no-results": "Nulle resultato", - "ep_adminpads2_pad-user-count": "Numero de usatores del pad", - "ep_adminpads2_padname": "Nomine del pad", - "ep_adminpads2_search-box.placeholder": "Termino de recerca", - "ep_adminpads2_search-button.value": "Cercar", - "ep_adminpads2_search-done": "Recerca terminate", - "ep_adminpads2_search-error-explanation": "Le servitor ha incontrate un error durante le recerca de pads:", - "ep_adminpads2_search-error-title": "Non poteva obtener le lista de pads", - "ep_adminpads2_search-heading": "Cercar pads", - "ep_adminpads2_title": "Administration de pads", - "ep_adminpads2_unknown-error": "Error incognite", - "ep_adminpads2_unknown-status": "Stato incognite" -} diff --git a/admin/public/ep_admin_pads/it.json b/admin/public/ep_admin_pads/it.json deleted file mode 100644 index 493cbb4d5..000000000 --- a/admin/public/ep_admin_pads/it.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Beta16", - "Luca.favorido" - ] - }, - "ep_adminpads2_action": "Azione", - "ep_adminpads2_delete.value": "Cancella", - "ep_adminpads2_last-edited": "Ultima modifica", - "ep_adminpads2_loading": "Caricamento…", - "ep_adminpads2_no-results": "Nessun risultato", - "ep_adminpads2_search-button.value": "Cerca", - "ep_adminpads2_unknown-error": "Errore sconosciuto", - "ep_adminpads2_unknown-status": "Stato sconosciuto" -} diff --git a/admin/public/ep_admin_pads/kn.json b/admin/public/ep_admin_pads/kn.json deleted file mode 100644 index 1e9019611..000000000 --- a/admin/public/ep_admin_pads/kn.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "@metadata": { - "authors": [ - "ಮಲ್ನಾಡಾಚ್ ಕೊಂಕ್ಣೊ" - ] - }, - "ep_adminpads2_action": "ಕ್ರಿಯೆ", - "ep_adminpads2_delete.value": "ಅಳಿಸು", - "ep_adminpads2_loading": "ತುಂಬಿಸಲಾಗುತ್ತಿದೆ…", - "ep_adminpads2_no-results": "ಯಾವ ಫಲಿತಾಂಶಗಳೂ ಇಲ್ಲ", - "ep_adminpads2_search-button.value": "ಹುಡುಕು", - "ep_adminpads2_unknown-error": "ಅಪರಿಚಿತ ದೋಷ" -} diff --git a/admin/public/ep_admin_pads/ko.json b/admin/public/ep_admin_pads/ko.json deleted file mode 100644 index 9ab8feed3..000000000 --- a/admin/public/ep_admin_pads/ko.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Ykhwong", - "그냥기여자" - ] - }, - "ep_adminpads2_action": "동작", - "ep_adminpads2_autoupdate-label": "패드 변경 시 자동 업데이트", - "ep_adminpads2_autoupdate.title": "현재 쿼리의 자동 업데이트를 활성화하거나 비활성화합니다.", - "ep_adminpads2_confirm": "{{padID}} 패드를 삭제하시겠습니까?", - "ep_adminpads2_delete.value": "삭제", - "ep_adminpads2_last-edited": "최근 편집", - "ep_adminpads2_loading": "불러오는 중...", - "ep_adminpads2_manage-pads": "패드 관리", - "ep_adminpads2_no-results": "결과 없음", - "ep_adminpads2_pad-user-count": "패드 사용자 수", - "ep_adminpads2_padname": "패드 이름", - "ep_adminpads2_search-box.placeholder": "검색어", - "ep_adminpads2_search-button.value": "검색", - "ep_adminpads2_search-done": "검색 완료", - "ep_adminpads2_search-error-explanation": "패드 검색 중 서버에 오류가 발생했습니다:", - "ep_adminpads2_search-error-title": "패드 목록 가져오기 실패", - "ep_adminpads2_search-heading": "패드 검색", - "ep_adminpads2_title": "패드 관리", - "ep_adminpads2_unknown-error": "알 수 없는 오류", - "ep_adminpads2_unknown-status": "알 수 없는 상태" -} diff --git a/admin/public/ep_admin_pads/krc.json b/admin/public/ep_admin_pads/krc.json deleted file mode 100644 index 2caf4f099..000000000 --- a/admin/public/ep_admin_pads/krc.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Къарачайлы" - ] - }, - "ep_adminpads2_action": "Этиу", - "ep_adminpads2_autoupdate-label": "Блокнот тюрлендириулеринде автомат халда джангыртыу", - "ep_adminpads2_autoupdate.title": "Баргъан излем ючюн автомат халда джангыртыуланы джандын неда джукълат.", - "ep_adminpads2_confirm": "{{padID}} блокнотну керти да кетерирге излеймисиз?", - "ep_adminpads2_delete.value": "Кетер", - "ep_adminpads2_last-edited": "Ахыр тюзетиу", - "ep_adminpads2_loading": "Джюклениу…", - "ep_adminpads2_manage-pads": "Блокнотланы оноуун эт", - "ep_adminpads2_no-results": "Эсебле джокъдула", - "ep_adminpads2_pad-user-count": "Блокнот хайырланыучуланы саны", - "ep_adminpads2_padname": "Блокнот ат", - "ep_adminpads2_search-box.placeholder": "Терминни изле", - "ep_adminpads2_search-button.value": "Изле", - "ep_adminpads2_search-done": "Излеу тамамланды", - "ep_adminpads2_search-error-explanation": "Сервер, блокнотланы излеген заманда халат табды:", - "ep_adminpads2_search-error-title": "Блокнот тизмеси алынамады", - "ep_adminpads2_search-heading": "Блокнотла ючюн излеу", - "ep_adminpads2_title": "Блокнот башчылыкъ", - "ep_adminpads2_unknown-error": "Билинмеген халат", - "ep_adminpads2_unknown-status": "Билинмеген турум" -} diff --git a/admin/public/ep_admin_pads/lb.json b/admin/public/ep_admin_pads/lb.json deleted file mode 100644 index 61aa2588d..000000000 --- a/admin/public/ep_admin_pads/lb.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Robby", - "Volvox" - ] - }, - "ep_adminpads2_confirm": "Wëllt Dir de Pad {{padID}} wierklech läschen?", - "ep_adminpads2_delete.value": "Läschen", - "ep_adminpads2_loading": "Lueden...", - "ep_adminpads2_no-results": "Keng Resultater", - "ep_adminpads2_padname": "Padnumm", - "ep_adminpads2_search-box.placeholder": "Sichbegrëff", - "ep_adminpads2_search-button.value": "Sichen", - "ep_adminpads2_unknown-error": "Onbekannte Feeler" -} diff --git a/admin/public/ep_admin_pads/lt.json b/admin/public/ep_admin_pads/lt.json deleted file mode 100644 index 59b2a13b3..000000000 --- a/admin/public/ep_admin_pads/lt.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Nokeoo" - ] - }, - "ep_adminpads2_action": "Veiksmas", - "ep_adminpads2_autoupdate-label": "Automatinis bloknoto keitimų naujinimas", - "ep_adminpads2_autoupdate.title": "Įjungia arba išjungia automatinius dabartinės užklausos atnaujinimus.", - "ep_adminpads2_confirm": "Ar tikrai norite ištrinti bloknotą {{padID}}?", - "ep_adminpads2_delete.value": "Ištrinti", - "ep_adminpads2_last-edited": "Paskutinis pakeitimas", - "ep_adminpads2_loading": "Įkeliama…", - "ep_adminpads2_manage-pads": "Tvarkyti bloknotą", - "ep_adminpads2_no-results": "Nėra rezultatų", - "ep_adminpads2_pad-user-count": "Bloknoto naudotojų skaičius", - "ep_adminpads2_padname": "Bloknoto pavadinimas", - "ep_adminpads2_search-box.placeholder": "Paieškos terminas", - "ep_adminpads2_search-button.value": "Paieška", - "ep_adminpads2_search-done": "Paieška baigta", - "ep_adminpads2_search-error-explanation": "Serveris susidūrė su klaida ieškant bloknotų:", - "ep_adminpads2_search-error-title": "Nepavyko gauti bloknotų sąrašo", - "ep_adminpads2_search-heading": "Ieškokite bloknotų", - "ep_adminpads2_title": "Bloknotų administravimas", - "ep_adminpads2_unknown-error": "Nežinoma klaida", - "ep_adminpads2_unknown-status": "Nežinoma būsena" -} diff --git a/admin/public/ep_admin_pads/mk.json b/admin/public/ep_admin_pads/mk.json deleted file mode 100644 index 72affd86c..000000000 --- a/admin/public/ep_admin_pads/mk.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Bjankuloski06" - ] - }, - "ep_adminpads2_action": "Дејство", - "ep_adminpads2_autoupdate-label": "Самоподнова при измени во тетратката", - "ep_adminpads2_autoupdate.title": "Овозможува или оневозможува самоподнова на тековното барање.", - "ep_adminpads2_confirm": "Дали навистина сакате да ја избришете тетратката {{padID}}?", - "ep_adminpads2_delete.value": "Избриши", - "ep_adminpads2_last-edited": "Последно уредување", - "ep_adminpads2_loading": "Вчитувам…", - "ep_adminpads2_manage-pads": "Раководење со тетратки", - "ep_adminpads2_no-results": "Нема исход", - "ep_adminpads2_pad-user-count": "Корисници на тетратката", - "ep_adminpads2_padname": "Назив на тетратката", - "ep_adminpads2_search-box.placeholder": "Пребаран поим", - "ep_adminpads2_search-button.value": "Пребарај", - "ep_adminpads2_search-done": "Пребарувањето заврши", - "ep_adminpads2_search-error-explanation": "Опслужувачот наиде на грешка при пребарувањето на тетратки:", - "ep_adminpads2_search-error-title": "Не можев да го добијам списокот на тетратки", - "ep_adminpads2_search-heading": "Пребарај по тетратките", - "ep_adminpads2_title": "Администрација на тетратки", - "ep_adminpads2_unknown-error": "Непозната грешка", - "ep_adminpads2_unknown-status": "Непозната состојба" -} diff --git a/admin/public/ep_admin_pads/my.json b/admin/public/ep_admin_pads/my.json deleted file mode 100644 index 6b94ba702..000000000 --- a/admin/public/ep_admin_pads/my.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Andibecker" - ] - }, - "ep_adminpads2_action": "လုပ်ဆောင်ချက်", - "ep_adminpads2_autoupdate-label": "pad အပြောင်းအလဲများတွင်အလိုအလျောက်အပ်ဒိတ်လုပ်ပါ", - "ep_adminpads2_autoupdate.title": "လက်ရှိမေးမြန်းမှုအတွက်အလိုအလျောက်အပ်ဒိတ်များကိုဖွင့်ပါသို့မဟုတ်ပိတ်ပါ။", - "ep_adminpads2_confirm": "pad {{padID}} ကိုသင်တကယ်ဖျက်ချင်လား။", - "ep_adminpads2_delete.value": "ဖျက်ပါ", - "ep_adminpads2_last-edited": "နောက်ဆုံးတည်းဖြတ်သည်", - "ep_adminpads2_loading": "ဖွင့်နေသည်…", - "ep_adminpads2_manage-pads": "pads များကိုစီမံပါ", - "ep_adminpads2_no-results": "ရလဒ်မရှိပါ", - "ep_adminpads2_pad-user-count": "Pad အသုံးပြုသူအရေအတွက်", - "ep_adminpads2_padname": "Padname", - "ep_adminpads2_search-box.placeholder": "ဝေါဟာရရှာဖွေပါ", - "ep_adminpads2_search-button.value": "ရှာဖွေပါ", - "ep_adminpads2_search-done": "ရှာဖွေမှုပြီးပါပြီ", - "ep_adminpads2_search-error-explanation": "pads များကိုရှာဖွေစဉ်ဆာဗာသည်အမှားတစ်ခုကြုံခဲ့သည်။", - "ep_adminpads2_search-error-title": "pad စာရင်းရယူရန်မအောင်မြင်ပါ", - "ep_adminpads2_search-heading": "pads များကိုရှာဖွေပါ", - "ep_adminpads2_title": "Pad စီမံခန့်ခွဲမှု", - "ep_adminpads2_unknown-error": "အမည်မသိအမှား", - "ep_adminpads2_unknown-status": "အခြေအနေမသိ" -} diff --git a/admin/public/ep_admin_pads/nb.json b/admin/public/ep_admin_pads/nb.json deleted file mode 100644 index acd194397..000000000 --- a/admin/public/ep_admin_pads/nb.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "@metadata": { - "authors": [ - "EdoAug" - ] - }, - "ep_adminpads2_action": "Handling", - "ep_adminpads2_last-edited": "Sist redigert", - "ep_adminpads2_loading": "Laster …", - "ep_adminpads2_no-results": "Ingen resultater", - "ep_adminpads2_search-button.value": "Søk", - "ep_adminpads2_search-done": "Søk fullført" -} diff --git a/admin/public/ep_admin_pads/nl.json b/admin/public/ep_admin_pads/nl.json deleted file mode 100644 index f4d97b351..000000000 --- a/admin/public/ep_admin_pads/nl.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Aranka", - "McDutchie", - "Spinster" - ] - }, - "ep_adminpads2_action": "Handeling", - "ep_adminpads2_autoupdate-label": "Automatisch bijwerken bij aanpassingen aan de pad", - "ep_adminpads2_autoupdate.title": "Schakelt automatische updates voor de huidige query in of uit.", - "ep_adminpads2_confirm": "Wil je de pad {{padID}} echt verwijderen?", - "ep_adminpads2_delete.value": "Verwijderen", - "ep_adminpads2_last-edited": "Laatst bewerkt", - "ep_adminpads2_loading": "Bezig met laden...", - "ep_adminpads2_manage-pads": "Pads beheren", - "ep_adminpads2_no-results": "Geen resultaten", - "ep_adminpads2_pad-user-count": "Aantal gebruikers van de pad", - "ep_adminpads2_padname": "Naam van de pad", - "ep_adminpads2_search-box.placeholder": "Zoekterm", - "ep_adminpads2_search-button.value": "Zoeken", - "ep_adminpads2_search-done": "Zoekopdracht voltooid", - "ep_adminpads2_search-error-explanation": "De server heeft een fout aangetroffen tijdens het zoeken naar pads:", - "ep_adminpads2_search-error-title": "Kan lijst met pads niet ophalen", - "ep_adminpads2_search-heading": "Pads zoeken", - "ep_adminpads2_title": "Administratie van pad", - "ep_adminpads2_unknown-error": "Onbekende fout", - "ep_adminpads2_unknown-status": "Onbekende status" -} diff --git a/admin/public/ep_admin_pads/oc.json b/admin/public/ep_admin_pads/oc.json deleted file mode 100644 index ae0169faf..000000000 --- a/admin/public/ep_admin_pads/oc.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Quentí" - ] - }, - "ep_adminpads2_action": "Accion", - "ep_adminpads2_delete.value": "Suprimir", - "ep_adminpads2_last-edited": "Darrièra edicion", - "ep_adminpads2_loading": "Cargament…", - "ep_adminpads2_manage-pads": "Gerir los pads", - "ep_adminpads2_no-results": "Pas cap de resultat", - "ep_adminpads2_padname": "Nom del pad", - "ep_adminpads2_search-box.placeholder": "Tèrme de recèrca", - "ep_adminpads2_search-button.value": "Recercar", - "ep_adminpads2_search-done": "Recèrca acabada", - "ep_adminpads2_search-heading": "Cercar de pads", - "ep_adminpads2_title": "Administracion de pad", - "ep_adminpads2_unknown-error": "Error desconeguda", - "ep_adminpads2_unknown-status": "Estat desconegut" -} diff --git a/admin/public/ep_admin_pads/pms.json b/admin/public/ep_admin_pads/pms.json deleted file mode 100644 index ac0542b85..000000000 --- a/admin/public/ep_admin_pads/pms.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Borichèt" - ] - }, - "ep_adminpads2_action": "Assion", - "ep_adminpads2_autoupdate-label": "Agiornament automàtich an sle modìfiche ëd plancia", - "ep_adminpads2_autoupdate.title": "Abilité o disabilité j'agiornament automàtich për l'arcesta atual.", - "ep_adminpads2_confirm": "Veul-lo për da bon dëscancelé la plancia {{padID}}?", - "ep_adminpads2_delete.value": "Dëscancelé", - "ep_adminpads2_last-edited": "Modificà l'ùltima vira", - "ep_adminpads2_loading": "Cariament…", - "ep_adminpads2_manage-pads": "Gestì le plance", - "ep_adminpads2_no-results": "Gnun arzultà", - "ep_adminpads2_pad-user-count": "Conteur ëd plancia dl'utent", - "ep_adminpads2_padname": "Nòm ëd plancia", - "ep_adminpads2_search-box.placeholder": "Tèrmin d'arserca", - "ep_adminpads2_search-button.value": "Arserca", - "ep_adminpads2_search-done": "Arserca completà", - "ep_adminpads2_search-error-explanation": "Ël servent a l'ha rancontrà n'eror an sërcand dle plance:", - "ep_adminpads2_search-error-title": "Falì a oten-e la lista ëd plance", - "ep_adminpads2_search-heading": "Arserca ëd plance", - "ep_adminpads2_title": "Aministrassion ëd plance", - "ep_adminpads2_unknown-error": "Eror nen conossù", - "ep_adminpads2_unknown-status": "Statù nen conossù" -} diff --git a/admin/public/ep_admin_pads/pt-br.json b/admin/public/ep_admin_pads/pt-br.json deleted file mode 100644 index 28a7874ee..000000000 --- a/admin/public/ep_admin_pads/pt-br.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Duke of Wikipädia", - "Eduardo Addad de Oliveira", - "Eduardoaddad", - "YuriNikolai" - ] - }, - "ep_adminpads2_action": "Ação", - "ep_adminpads2_autoupdate-label": "Atualizar notas automaticamente", - "ep_adminpads2_autoupdate.title": "Habilita ou desabilita atualizações automáticas para a consulta atual.", - "ep_adminpads2_confirm": "Você realmente deseja excluir a nota {{padID}}?", - "ep_adminpads2_delete.value": "Excluir", - "ep_adminpads2_last-edited": "Última edição", - "ep_adminpads2_loading": "Carregando…", - "ep_adminpads2_manage-pads": "Gerenciar notas", - "ep_adminpads2_no-results": "Sem resultados", - "ep_adminpads2_pad-user-count": "Número de utilizadores na nota", - "ep_adminpads2_padname": "Nome da nota", - "ep_adminpads2_search-box.placeholder": "Termo de pesquisa", - "ep_adminpads2_search-button.value": "Pesquisar", - "ep_adminpads2_search-done": "Busca completa", - "ep_adminpads2_search-error-explanation": "O servidor encontrou um erro enquanto procurava por notas:", - "ep_adminpads2_search-error-title": "Falha ao buscar lista de notas", - "ep_adminpads2_search-heading": "Pesquisar por notas", - "ep_adminpads2_title": "Administração de notas", - "ep_adminpads2_unknown-error": "Erro desconhecido", - "ep_adminpads2_unknown-status": "Status desconhecido" -} diff --git a/admin/public/ep_admin_pads/pt.json b/admin/public/ep_admin_pads/pt.json deleted file mode 100644 index b7abf2f3f..000000000 --- a/admin/public/ep_admin_pads/pt.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Guilha" - ] - }, - "ep_adminpads2_action": "Ação", - "ep_adminpads2_autoupdate-label": "Atualizar automaticamente as notas", - "ep_adminpads2_autoupdate.title": "Ativa ou desativa atualizações automáticas na consulta atual.", - "ep_adminpads2_confirm": "Tencionas mesmo eliminar a nota {{padID}}?", - "ep_adminpads2_delete.value": "Eliminar", - "ep_adminpads2_last-edited": "Última edição", - "ep_adminpads2_loading": "A carregar...", - "ep_adminpads2_manage-pads": "Gerir notas", - "ep_adminpads2_no-results": "Sem resultados", - "ep_adminpads2_pad-user-count": "Número de utilizadores na nota", - "ep_adminpads2_padname": "Nome da nota", - "ep_adminpads2_search-box.placeholder": "Procurar termo", - "ep_adminpads2_search-button.value": "Procurar", - "ep_adminpads2_search-done": "Procura completa", - "ep_adminpads2_search-error-explanation": "O servidor encontrou um erro enquanto procurava por notas:", - "ep_adminpads2_search-error-title": "Falha ao obter lista de notas", - "ep_adminpads2_search-heading": "Procurar por notas", - "ep_adminpads2_title": "Administração da nota", - "ep_adminpads2_unknown-error": "Erro desconhecido", - "ep_adminpads2_unknown-status": "Estado desconhecido" -} diff --git a/admin/public/ep_admin_pads/qqq.json b/admin/public/ep_admin_pads/qqq.json deleted file mode 100644 index de36e2ae6..000000000 --- a/admin/public/ep_admin_pads/qqq.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "@metadata": { - "authors": [ - "BryanDavis" - ] - }, - "ep_adminpads2_action": "{{Identical|Action}}", - "ep_adminpads2_delete.value": "{{Identical|Delete}}", - "ep_adminpads2_search-button.value": "{{Identical|Search}}" -} diff --git a/admin/public/ep_admin_pads/ru.json b/admin/public/ep_admin_pads/ru.json deleted file mode 100644 index 6d0d163d0..000000000 --- a/admin/public/ep_admin_pads/ru.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "@metadata": { - "authors": [ - "DDPAT", - "Ice bulldog", - "Megakott", - "Okras", - "Pacha Tchernof" - ] - }, - "ep_adminpads2_action": "Действие", - "ep_adminpads2_autoupdate-label": "Автообновление при изменении документа", - "ep_adminpads2_autoupdate.title": "Включает или отключает автоматические обновления для текущего запроса.", - "ep_adminpads2_confirm": "Вы действительно хотите удалить документ {{padID}}?", - "ep_adminpads2_delete.value": "Удалить", - "ep_adminpads2_last-edited": "Последнее изменение", - "ep_adminpads2_loading": "Загружается…", - "ep_adminpads2_manage-pads": "Управление документами", - "ep_adminpads2_no-results": "Нет результатов", - "ep_adminpads2_pad-user-count": "Количество пользователей документа", - "ep_adminpads2_padname": "Название документа", - "ep_adminpads2_search-box.placeholder": "Искать термин", - "ep_adminpads2_search-button.value": "Найти", - "ep_adminpads2_search-done": "Поиск завершён", - "ep_adminpads2_search-error-explanation": "Сервер обнаружил ошибку при поиске документов:", - "ep_adminpads2_search-error-title": "Не удалось получить список документов", - "ep_adminpads2_search-heading": "Поиск документов", - "ep_adminpads2_title": "Администрирование документов", - "ep_adminpads2_unknown-error": "Неизвестная ошибка", - "ep_adminpads2_unknown-status": "Неизвестный статус" -} diff --git a/admin/public/ep_admin_pads/sc.json b/admin/public/ep_admin_pads/sc.json deleted file mode 100644 index a37bba5a2..000000000 --- a/admin/public/ep_admin_pads/sc.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Adr mm" - ] - }, - "ep_adminpads2_action": "Atzione", - "ep_adminpads2_autoupdate-label": "Atualizatzione automàtica de is modìficas de su pad", - "ep_adminpads2_autoupdate.title": "Ativat o disativat is atualizatziones automàticas pro sa chirca atuale.", - "ep_adminpads2_confirm": "Seguru chi boles cantzellare su pad {{padID}}?", - "ep_adminpads2_delete.value": "Cantzella", - "ep_adminpads2_last-edited": "Ùrtima modìfica", - "ep_adminpads2_loading": "Carrighende...", - "ep_adminpads2_manage-pads": "Gesti is pads", - "ep_adminpads2_no-results": "Nissunu resurtadu", - "ep_adminpads2_pad-user-count": "Nùmeru de utentes de pads", - "ep_adminpads2_padname": "Nòmine de su pad", - "ep_adminpads2_search-box.placeholder": "Tèrmine de chirca", - "ep_adminpads2_search-button.value": "Chirca", - "ep_adminpads2_search-done": "Chirca cumpleta", - "ep_adminpads2_search-error-explanation": "Su serbidore at agatadu un'errore chirchende pads:", - "ep_adminpads2_search-error-title": "Impossìbile otènnere sa lista de pads", - "ep_adminpads2_search-heading": "Chirca pads", - "ep_adminpads2_title": "Amministratzione de su pad", - "ep_adminpads2_unknown-error": "Errore disconnotu", - "ep_adminpads2_unknown-status": "Istadu disconnotu" -} diff --git a/admin/public/ep_admin_pads/sdc.json b/admin/public/ep_admin_pads/sdc.json deleted file mode 100644 index c4672fd7f..000000000 --- a/admin/public/ep_admin_pads/sdc.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "@metadata": { - "authors": [ - "F Samaritani" - ] - }, - "ep_adminpads2_action": "Azioni", - "ep_adminpads2_delete.value": "Canzella", - "ep_adminpads2_loading": "carrigghendi...", - "ep_adminpads2_no-results": "Nisciun risulthaddu", - "ep_adminpads2_search-button.value": "Zercha", - "ep_adminpads2_search-heading": "Zirchà dati", - "ep_adminpads2_unknown-error": "Errori ischunisciddu" -} diff --git a/admin/public/ep_admin_pads/sk.json b/admin/public/ep_admin_pads/sk.json deleted file mode 100644 index ab0392d4e..000000000 --- a/admin/public/ep_admin_pads/sk.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Yardom78" - ] - }, - "ep_adminpads2_action": "Akcia", - "ep_adminpads2_autoupdate-label": "Automatická aktualizácia zmien na poznámkovom bloku", - "ep_adminpads2_autoupdate.title": "Zapne alebo vypne automatickú aktualizáciu.", - "ep_adminpads2_confirm": "Skutočne chcete vymazať poznámkový blok {{padID}}?", - "ep_adminpads2_delete.value": "Vymazať", - "ep_adminpads2_last-edited": "Posledná úprava", - "ep_adminpads2_loading": "Načítavanie...", - "ep_adminpads2_manage-pads": "Spravovať poznámkové bloky", - "ep_adminpads2_no-results": "Žiadne výsledky", - "ep_adminpads2_pad-user-count": "Počet používateľov poznámkového bloku", - "ep_adminpads2_padname": "Názov poznámkového bloku", - "ep_adminpads2_search-box.placeholder": "Hľadať výraz", - "ep_adminpads2_search-button.value": "Hľadať", - "ep_adminpads2_search-done": "Hľadanie dokončené", - "ep_adminpads2_search-error-explanation": "Pri hľadaní poznámkového bloku došlo k chybe:", - "ep_adminpads2_search-error-title": "Nepodarilo sa získať zoznam poznámkových blokov", - "ep_adminpads2_search-heading": "Hľadať poznámkový blok", - "ep_adminpads2_title": "Správa poznámkového bloku", - "ep_adminpads2_unknown-error": "Neznáma chyba", - "ep_adminpads2_unknown-status": "Neznámy stav" -} diff --git a/admin/public/ep_admin_pads/skr-arab.json b/admin/public/ep_admin_pads/skr-arab.json deleted file mode 100644 index 08162f849..000000000 --- a/admin/public/ep_admin_pads/skr-arab.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Saraiki" - ] - }, - "ep_adminpads2_action": "عمل", - "ep_adminpads2_delete.value": "مٹاؤ", - "ep_adminpads2_last-edited": "چھیکڑی تبدیلی", - "ep_adminpads2_loading": "لوڈ تھین٘دا پئے۔۔۔", - "ep_adminpads2_manage-pads": "پیڈ منیج کرو", - "ep_adminpads2_no-results": "کوئی نتیجہ کائنی", - "ep_adminpads2_padname": "پیڈ ناں", - "ep_adminpads2_search-box.placeholder": "ٹرم ڳولو", - "ep_adminpads2_search-button.value": "ڳولو", - "ep_adminpads2_search-done": "ڳولݨ پورا تھیا", - "ep_adminpads2_search-heading": "پیڈاں دی ڳول", - "ep_adminpads2_unknown-error": "نامعلوم غلطی", - "ep_adminpads2_unknown-status": "نامعلوم حالت" -} diff --git a/admin/public/ep_admin_pads/sl.json b/admin/public/ep_admin_pads/sl.json deleted file mode 100644 index 3bebe1972..000000000 --- a/admin/public/ep_admin_pads/sl.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Eleassar", - "HairyFotr" - ] - }, - "ep_adminpads2_action": "Dejanje", - "ep_adminpads2_autoupdate-label": "Samodejno posodabljanje ob spremembah blokcev", - "ep_adminpads2_autoupdate.title": "Omogoči ali onemogoči samodejne posodobitve za trenutno poizvedbo.", - "ep_adminpads2_confirm": "Ali res želite izbrisati blokec {{padID}}?", - "ep_adminpads2_delete.value": "Izbriši", - "ep_adminpads2_last-edited": "Zadnje urejanje", - "ep_adminpads2_loading": "Nalaganje ...", - "ep_adminpads2_manage-pads": "Upravljanje blokcev", - "ep_adminpads2_no-results": "Ni zadetkov", - "ep_adminpads2_pad-user-count": "Število urejevalcev blokca", - "ep_adminpads2_padname": "Ime blokca", - "ep_adminpads2_search-box.placeholder": "Iskalni izraz", - "ep_adminpads2_search-button.value": "Išči", - "ep_adminpads2_search-done": "Iskanje končano", - "ep_adminpads2_search-error-explanation": "Strežnik je med iskanjem blokcev naletel na napako:", - "ep_adminpads2_search-error-title": "Ni bilo mogoče pridobiti seznama blokcev", - "ep_adminpads2_search-heading": "Iskanje blokcev", - "ep_adminpads2_title": "Upravljanje blokcev", - "ep_adminpads2_unknown-error": "Neznana napaka", - "ep_adminpads2_unknown-status": "Neznano stanje" -} diff --git a/admin/public/ep_admin_pads/smn.json b/admin/public/ep_admin_pads/smn.json deleted file mode 100644 index 9d57cc73c..000000000 --- a/admin/public/ep_admin_pads/smn.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Yupik" - ] - }, - "ep_adminpads2_delete.value": "Siho", - "ep_adminpads2_last-edited": "Majemustáá nubástittum", - "ep_adminpads2_search-box.placeholder": "Uuccâmsääni", - "ep_adminpads2_search-button.value": "Uusâ", - "ep_adminpads2_unknown-error": "Tubdâmettum feilâ", - "ep_adminpads2_unknown-status": "Tubdâmettum tile" -} diff --git a/admin/public/ep_admin_pads/sms.json b/admin/public/ep_admin_pads/sms.json deleted file mode 100644 index 8d3cf5797..000000000 --- a/admin/public/ep_admin_pads/sms.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Yupik" - ] - }, - "ep_adminpads2_delete.value": "Jaukkâd", - "ep_adminpads2_last-edited": "Mââimõssân muttum", - "ep_adminpads2_no-results": "Ij käunnʼjam ni mii", - "ep_adminpads2_padname": "Mošttʼtõspõʹmmai nõmm", - "ep_adminpads2_search-box.placeholder": "Ooccâmsääʹnn", - "ep_adminpads2_search-button.value": "Ooʒʒ", - "ep_adminpads2_search-heading": "Ooʒʒ mošttʼtõspõʹmmjid", - "ep_adminpads2_unknown-error": "Toobdteʹmes vââʹǩǩ", - "ep_adminpads2_unknown-status": "Toobdteʹmes status" -} diff --git a/admin/public/ep_admin_pads/sq.json b/admin/public/ep_admin_pads/sq.json deleted file mode 100644 index cc4740763..000000000 --- a/admin/public/ep_admin_pads/sq.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Besnik b" - ] - }, - "ep_adminpads2_action": "Veprim", - "ep_adminpads2_autoupdate-label": "Vetëpërditësohu, kur nga ndryshime blloku", - "ep_adminpads2_autoupdate.title": "Aktivizon ose çaktivizon përditësim të automatizuara për kërkesën e tanishme.", - "ep_adminpads2_confirm": "Doni vërtet të fshihet blloku {{padID}}?", - "ep_adminpads2_delete.value": "Fshije", - "ep_adminpads2_last-edited": "Përpunuar së fundi më", - "ep_adminpads2_loading": "Po ngarkohet…", - "ep_adminpads2_manage-pads": "Administroni blloqe", - "ep_adminpads2_no-results": "S’ka përfundime", - "ep_adminpads2_pad-user-count": "Numër përdoruesish blloku", - "ep_adminpads2_padname": "Emër blloku", - "ep_adminpads2_search-box.placeholder": "Term kërkimi", - "ep_adminpads2_search-button.value": "Kërko", - "ep_adminpads2_search-done": "Kërkim i plotë", - "ep_adminpads2_search-error-explanation": "Shërbyesi hasi një gabim teksa kërkohej për blloqe:", - "ep_adminpads2_search-error-title": "S’u arrit të merrej listë blloqesh", - "ep_adminpads2_search-heading": "Kërkoni për blloqe", - "ep_adminpads2_title": "Administrim blloku", - "ep_adminpads2_unknown-error": "Gabim i panjohur", - "ep_adminpads2_unknown-status": "Gjendje e panjohur" -} diff --git a/admin/public/ep_admin_pads/sv.json b/admin/public/ep_admin_pads/sv.json deleted file mode 100644 index e77aaf2c4..000000000 --- a/admin/public/ep_admin_pads/sv.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Bengtsson96", - "WikiPhoenix" - ] - }, - "ep_adminpads2_action": "Åtgärd", - "ep_adminpads2_autoupdate-label": "Uppdatera automatiskt när blocket ändras", - "ep_adminpads2_autoupdate.title": "Aktivera eller inaktivera automatiska uppdatering för nuvarande förfrågan.", - "ep_adminpads2_confirm": "Vill du verkligen radera blocket {{padID}}?", - "ep_adminpads2_delete.value": "Radera", - "ep_adminpads2_last-edited": "Senast redigerad", - "ep_adminpads2_loading": "Läser in …", - "ep_adminpads2_manage-pads": "Hantera block", - "ep_adminpads2_no-results": "Inga resultat", - "ep_adminpads2_pad-user-count": "Antal blockanvändare", - "ep_adminpads2_padname": "Blocknamn", - "ep_adminpads2_search-box.placeholder": "Sökord", - "ep_adminpads2_search-button.value": "Sök", - "ep_adminpads2_search-done": "Sökning slutförd", - "ep_adminpads2_search-error-explanation": "Servern stötte på ett fel vid sökning efter block:", - "ep_adminpads2_search-error-title": "Misslyckades att hämta blocklista", - "ep_adminpads2_search-heading": "Sök efter block", - "ep_adminpads2_title": "Blockadministration", - "ep_adminpads2_unknown-error": "Okänt fel", - "ep_adminpads2_unknown-status": "Okänd status" -} diff --git a/admin/public/ep_admin_pads/sw.json b/admin/public/ep_admin_pads/sw.json deleted file mode 100644 index f1beeecbb..000000000 --- a/admin/public/ep_admin_pads/sw.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Andibecker" - ] - }, - "ep_adminpads2_action": "Hatua", - "ep_adminpads2_autoupdate-label": "Sasisha kiotomatiki kwenye mabadiliko ya pedi", - "ep_adminpads2_autoupdate.title": "Huwasha au kulemaza sasisho otomatiki kwa hoja ya sasa.", - "ep_adminpads2_confirm": "Je! Kweli unataka kufuta pedi {{padID}}?", - "ep_adminpads2_delete.value": "Futa", - "ep_adminpads2_last-edited": "Ilihaririwa mwisho", - "ep_adminpads2_loading": "Inapakia...", - "ep_adminpads2_manage-pads": "Dhibiti pedi", - "ep_adminpads2_no-results": "Hakuna matokeo", - "ep_adminpads2_pad-user-count": "Hesabu ya mtumiaji wa pedi", - "ep_adminpads2_padname": "Jina la utani", - "ep_adminpads2_search-box.placeholder": "Neno la utaftaji", - "ep_adminpads2_search-button.value": "Tafuta", - "ep_adminpads2_search-done": "Utafutaji umekamilika", - "ep_adminpads2_search-error-explanation": "Seva ilipata hitilafu wakati wa kutafuta pedi:", - "ep_adminpads2_search-error-title": "Imeshindwa kupata orodha ya pedi", - "ep_adminpads2_search-heading": "Tafuta pedi", - "ep_adminpads2_title": "Usimamizi wa pedi", - "ep_adminpads2_unknown-error": "Hitilafu isiyojulikana", - "ep_adminpads2_unknown-status": "Hali isiyojulikana" -} diff --git a/admin/public/ep_admin_pads/th.json b/admin/public/ep_admin_pads/th.json deleted file mode 100644 index 693e3f797..000000000 --- a/admin/public/ep_admin_pads/th.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Andibecker" - ] - }, - "ep_adminpads2_action": "การกระทำ", - "ep_adminpads2_autoupdate-label": "อัปเดตอัตโนมัติเมื่อเปลี่ยนแผ่น", - "ep_adminpads2_autoupdate.title": "เปิดหรือปิดการอัปเดตอัตโนมัติสำหรับคิวรีปัจจุบัน", - "ep_adminpads2_confirm": "คุณต้องการลบแพด {{padID}} จริงหรือไม่", - "ep_adminpads2_delete.value": "ลบ", - "ep_adminpads2_last-edited": "แก้ไขล่าสุด", - "ep_adminpads2_loading": "กำลังโหลด…", - "ep_adminpads2_manage-pads": "จัดการแผ่นรอง", - "ep_adminpads2_no-results": "ไม่มีผลลัพธ์", - "ep_adminpads2_pad-user-count": "จำนวนผู้ใช้แพด", - "ep_adminpads2_padname": "นามแฝง", - "ep_adminpads2_search-box.placeholder": "คำที่ต้องการค้นหา", - "ep_adminpads2_search-button.value": "ค้นหา", - "ep_adminpads2_search-done": "ค้นหาเสร็จสมบูรณ์", - "ep_adminpads2_search-error-explanation": "เซิร์ฟเวอร์พบข้อผิดพลาดขณะค้นหาแผ่นอิเล็กโทรด:", - "ep_adminpads2_search-error-title": "ไม่สามารถรับรายการแผ่นรอง", - "ep_adminpads2_search-heading": "ค้นหาแผ่นรอง", - "ep_adminpads2_title": "การบริหารแผ่น", - "ep_adminpads2_unknown-error": "ข้อผิดพลาดที่ไม่รู้จัก", - "ep_adminpads2_unknown-status": "ไม่ทราบสถานะ" -} diff --git a/admin/public/ep_admin_pads/tl.json b/admin/public/ep_admin_pads/tl.json deleted file mode 100644 index 238e01236..000000000 --- a/admin/public/ep_admin_pads/tl.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Mrkczr" - ] - }, - "ep_adminpads2_action": "Kilos", - "ep_adminpads2_delete.value": "Burahin", - "ep_adminpads2_last-edited": "Huling binago", - "ep_adminpads2_loading": "Naglo-load...", - "ep_adminpads2_no-results": "Walang mga resulta", - "ep_adminpads2_search-box.placeholder": "Mga katagang hahanapin:", - "ep_adminpads2_search-button.value": "Hanapin", - "ep_adminpads2_search-done": "Natapos na ang paghahanap", - "ep_adminpads2_unknown-error": "Hindi nalalamang kamalian", - "ep_adminpads2_unknown-status": "Hindi alam na katayuan" -} diff --git a/admin/public/ep_admin_pads/tr.json b/admin/public/ep_admin_pads/tr.json deleted file mode 100644 index 7e2e9d402..000000000 --- a/admin/public/ep_admin_pads/tr.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "@metadata": { - "authors": [ - "Hedda", - "MuratTheTurkish" - ] - }, - "ep_adminpads2_action": "Eylem", - "ep_adminpads2_autoupdate-label": "Bloknot değişikliklerinde otomatik güncelleme", - "ep_adminpads2_autoupdate.title": "Mevcut sorgu için otomatik güncellemeleri etkinleştirir veya devre dışı bırakır.", - "ep_adminpads2_confirm": "{{padID}} bloknotunu gerçekten silmek istiyor musunuz?", - "ep_adminpads2_delete.value": "Sil", - "ep_adminpads2_last-edited": "Son düzenleme", - "ep_adminpads2_loading": "Yükleniyor...", - "ep_adminpads2_manage-pads": "Bloknotları yönet", - "ep_adminpads2_no-results": "Sonuç yok", - "ep_adminpads2_pad-user-count": "Bloknot kullanıcı sayısı", - "ep_adminpads2_padname": "Bloknot adı", - "ep_adminpads2_search-box.placeholder": "Terimi ara", - "ep_adminpads2_search-button.value": "Ara", - "ep_adminpads2_search-done": "Arama tamamlandı", - "ep_adminpads2_search-error-explanation": "Sunucu, bloknotları ararken bir hatayla karşılaştı:", - "ep_adminpads2_search-error-title": "Bloknot listesi alınamadı", - "ep_adminpads2_search-heading": "Bloknotları ara", - "ep_adminpads2_title": "Bloknot yönetimi", - "ep_adminpads2_unknown-error": "Bilinmeyen hata", - "ep_adminpads2_unknown-status": "Bilinmeyen durum" -} diff --git a/admin/public/ep_admin_pads/uk.json b/admin/public/ep_admin_pads/uk.json deleted file mode 100644 index c5c95f722..000000000 --- a/admin/public/ep_admin_pads/uk.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "@metadata": { - "authors": [ - "DDPAT", - "Ice bulldog" - ] - }, - "ep_adminpads2_action": "Дія", - "ep_adminpads2_autoupdate-label": "Автоматичне оновлення при зміні майданчика", - "ep_adminpads2_autoupdate.title": "Вмикає або вимикає автоматичне оновлення поточного запиту.", - "ep_adminpads2_confirm": "Ви дійсно хочете видалити панель {{padID}}?", - "ep_adminpads2_delete.value": "Видалити", - "ep_adminpads2_last-edited": "Останнє редагування", - "ep_adminpads2_loading": "Завантаження…", - "ep_adminpads2_manage-pads": "Управління майданчиками", - "ep_adminpads2_no-results": "Немає результатів", - "ep_adminpads2_pad-user-count": "Кількість майданчиків користувача", - "ep_adminpads2_padname": "Назва майданчика", - "ep_adminpads2_search-box.placeholder": "Пошуковий термін", - "ep_adminpads2_search-button.value": "Пошук", - "ep_adminpads2_search-done": "Пошук завершено", - "ep_adminpads2_search-error-explanation": "Під час пошуку педів сервер виявив помилку:", - "ep_adminpads2_search-error-title": "Не вдалося отримати список панелей", - "ep_adminpads2_search-heading": "Пошук майданчиків", - "ep_adminpads2_title": "Введення майданчиків", - "ep_adminpads2_unknown-error": "Невідома помилка", - "ep_adminpads2_unknown-status": "Невідомий статус" -} diff --git a/admin/public/ep_admin_pads/zh-hans.json b/admin/public/ep_admin_pads/zh-hans.json deleted file mode 100644 index cdf0d945f..000000000 --- a/admin/public/ep_admin_pads/zh-hans.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "@metadata": { - "authors": [ - "GuoPC", - "Lakejason0", - "沈澄心" - ] - }, - "ep_adminpads2_action": "操作", - "ep_adminpads2_autoupdate-label": "在记事本更改时自动更新", - "ep_adminpads2_autoupdate.title": "启用或禁用目前查询的自动更新", - "ep_adminpads2_confirm": "您确定要删除记事本 {{padID}}?", - "ep_adminpads2_delete.value": "删除", - "ep_adminpads2_last-edited": "上次编辑于", - "ep_adminpads2_loading": "正在加载…", - "ep_adminpads2_manage-pads": "管理记事本", - "ep_adminpads2_no-results": "没有结果", - "ep_adminpads2_pad-user-count": "记事本用户数", - "ep_adminpads2_padname": "记事本名称", - "ep_adminpads2_search-box.placeholder": "搜索关键词", - "ep_adminpads2_search-button.value": "搜索", - "ep_adminpads2_search-done": "搜索完成", - "ep_adminpads2_search-error-explanation": "搜索记事本时服务器发生错误:", - "ep_adminpads2_search-error-title": "获取记事本列表失败", - "ep_adminpads2_search-heading": "搜索记事本", - "ep_adminpads2_title": "记事本管理", - "ep_adminpads2_unknown-error": "未知错误", - "ep_adminpads2_unknown-status": "未知状态" -} diff --git a/admin/public/ep_admin_pads/zh-hant.json b/admin/public/ep_admin_pads/zh-hant.json deleted file mode 100644 index daeed55f5..000000000 --- a/admin/public/ep_admin_pads/zh-hant.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "@metadata": { - "authors": [ - "HellojoeAoPS", - "Kly" - ] - }, - "ep_adminpads2_action": "操作", - "ep_adminpads2_autoupdate-label": "在記事本更改時自動更新", - "ep_adminpads2_autoupdate.title": "啟用或停用目前查詢的自動更新。", - "ep_adminpads2_confirm": "您確定要刪除記事本 {{padID}}?", - "ep_adminpads2_delete.value": "刪除", - "ep_adminpads2_last-edited": "上一次編輯", - "ep_adminpads2_loading": "載入中…", - "ep_adminpads2_manage-pads": "管理記事本", - "ep_adminpads2_no-results": "沒有結果", - "ep_adminpads2_pad-user-count": "記事本使用者數", - "ep_adminpads2_padname": "記事本名稱", - "ep_adminpads2_search-box.placeholder": "搜尋關鍵字", - "ep_adminpads2_search-button.value": "搜尋", - "ep_adminpads2_search-done": "搜尋完成", - "ep_adminpads2_search-error-explanation": "當搜尋記事本時伺服器發生錯誤:", - "ep_adminpads2_search-error-title": "取得記事本清單失敗", - "ep_adminpads2_search-heading": "搜尋記事本", - "ep_adminpads2_title": "記事本管理", - "ep_adminpads2_unknown-error": "不明錯誤", - "ep_adminpads2_unknown-status": "不明狀態" -} diff --git a/admin/public/fond.jpg b/admin/public/fond.jpg deleted file mode 100644 index 81357c7bb..000000000 Binary files a/admin/public/fond.jpg and /dev/null differ diff --git a/admin/src/App.css b/admin/src/App.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/admin/src/App.tsx b/admin/src/App.tsx deleted file mode 100644 index ae23ab3d3..000000000 --- a/admin/src/App.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import {useEffect, useState} from 'react' -import './App.css' -import {connect} from 'socket.io-client' -import {isJSONClean} from './utils/utils.ts' -import {NavLink, Outlet, useNavigate} from "react-router-dom"; -import {useStore} from "./store/store.ts"; -import {LoadingScreen} from "./utils/LoadingScreen.tsx"; -import {Trans, useTranslation} from "react-i18next"; -import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu} from "lucide-react"; - -const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : '' -export const App = () => { - const setSettings = useStore(state => state.setSettings); - const {t} = useTranslation() - const navigate = useNavigate() - const [sidebarOpen, setSidebarOpen] = useState(true) - - useEffect(() => { - fetch('/admin-auth/', { - method: 'POST' - }).then((value) => { - if (!value.ok) { - navigate('/login') - } - }).catch(() => { - navigate('/login') - }) - }, []); - - useEffect(() => { - document.title = t('admin.page-title') - - useStore.getState().setShowLoading(true); - const settingSocket = connect(`${WS_URL}/settings`, { - transports: ['websocket'], - }); - - const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, { - transports: ['websocket'], - }) - - pluginsSocket.on('connect', () => { - useStore.getState().setPluginsSocket(pluginsSocket); - }); - - - settingSocket.on('connect', () => { - useStore.getState().setSettingsSocket(settingSocket); - useStore.getState().setShowLoading(false) - settingSocket.emit('load'); - console.log('connected'); - }); - - settingSocket.on('disconnect', (reason) => { - // The settingSocket.io client will automatically try to reconnect for all reasons other than "io - // server disconnect". - useStore.getState().setShowLoading(true) - if (reason === 'io server disconnect') { - settingSocket.connect(); - } - }); - - settingSocket.on('settings', (settings) => { - /* Check whether the settings.json is authorized to be viewed */ - if (settings.results === 'NOT_ALLOWED') { - console.log('Not allowed to view settings.json') - return; - } - - /* Check to make sure the JSON is clean before proceeding */ - if (isJSONClean(settings.results)) { - setSettings(settings.results); - } else { - alert('Invalid JSON'); - } - useStore.getState().setShowLoading(false); - }); - - settingSocket.on('saveprogress', (status) => { - console.log(status) - }) - - return () => { - settingSocket.disconnect(); - pluginsSocket.disconnect() - } - }, []); - - return
- -
-
- - -

Etherpad

-
-
    { - if (window.innerWidth < 768) { - setSidebarOpen(false) - } - }}> -
  • -
  • -
  • -
  • -
  • Communication
  • -
-
-
- -
- -
-
-} - -export default App diff --git a/admin/src/components/IconButton.tsx b/admin/src/components/IconButton.tsx deleted file mode 100644 index 876a55779..000000000 --- a/admin/src/components/IconButton.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {FC, JSX, ReactElement} from "react"; - -export type IconButtonProps = { - icon: JSX.Element, - title: string|ReactElement, - onClick: ()=>void, - className?: string, - disabled?: boolean -} - -export const IconButton:FC = ({icon,className,onClick,title, disabled})=>{ - return -} diff --git a/admin/src/components/SearchField.tsx b/admin/src/components/SearchField.tsx deleted file mode 100644 index 62a965d40..000000000 --- a/admin/src/components/SearchField.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {ChangeEventHandler, FC} from "react"; -import {Search} from 'lucide-react' -export type SearchFieldProps = { - value: string, - onChange: ChangeEventHandler, - placeholder?: string -} - -export const SearchField:FC = ({onChange,value, placeholder})=>{ - return - - - -} diff --git a/admin/src/components/ShoutType.ts b/admin/src/components/ShoutType.ts deleted file mode 100644 index f7e8b1df3..000000000 --- a/admin/src/components/ShoutType.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type ShoutType = { - type: string, - data:{ - type: string, - payload: { - message: { - message: string, - sticky: boolean - }, - timestamp: number - } - } -} diff --git a/admin/src/index.css b/admin/src/index.css deleted file mode 100644 index acc0d2e97..000000000 --- a/admin/src/index.css +++ /dev/null @@ -1,870 +0,0 @@ -:root { - --etherpad-color: #0f775b; - --etherpad-comp: #9C8840; - --etherpad-light: #99FF99; - --sidebar-width: 20em; -} - -@font-face { - font-family: Karla; - src: url(/Karla-Regular.ttf); -} - -html, body, #root { - box-sizing: border-box; - height: 100%; - font-family: "Karla", sans-serif; -} - -*, *:before, *:after { - box-sizing: inherit; - font-size: 16px; -} - -body { - margin: 0; - color: #333; - font: 14px helvetica, sans-serif; - background: #eee; -} - -div.menu { - left: 0; - transition: left .3s; - height: 100vh; - font-size: 16px; - font-weight: bolder; - display: flex; - align-items: center; - justify-content: center; - width: var(--sidebar-width); - z-index: 99; - position: fixed; -} - - -.icon-button { - display: flex; - gap: 10px; - background-color: var(--etherpad-color); - color: white; - border: none; - padding: 10px 20px; - border-radius: 5px; - cursor: pointer; -} - -.icon-button svg { - align-self: center; -} - -.icon-button span { - align-self: center; -} - - -div.menu span:first-child { - display: flex; - justify-content: center; -} - -div.menu span:first-child svg { - margin-right: 10px; - align-self: center; -} - - -div.menu h1 { - font-size: 50px; - text-align: center; -} - -.inner-menu { - border-radius: 0 20px 20px 0; - padding: 10px; - flex-grow: 100; - background-color: var(--etherpad-comp); - color: white; - height: 100vh; -} - -div.menu ul { - color: white; - padding: 0; -} - -div.menu li a { - display: flex; - gap: 10px; - margin-bottom: 20px; -} - -div.menu svg { - align-self: center; -} - -div.menu li { - padding: 10px; - color: white; - list-style: none; - margin-left: 3px; - line-height: 3; -} - - -div.menu li:has(.active) { - background-color: #9C885C; -} - -div.menu li a { - color: lightgray; -} - - -div.innerwrapper { - transition: margin-left .3s; - isolation: isolate; - background-color: #F0F0F0; - overflow: auto; - height: 100vh; - flex-grow: 100; - margin-left: var(--sidebar-width); - padding: 20px 20px 20px; -} - -div.innerwrapper-err { - display: none; -} - -#wrapper { - background: none repeat scroll 0px 0px #FFFFFF; - box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.2); - min-height: 100%; /*always display a scrollbar*/ -} - -h1 { - font-size: 29px; -} - -h2 { - font-size: 24px; -} - -.separator { - margin: 10px 0; - height: 1px; - background: #aaa; - background: -webkit-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); - background: -moz-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); - background: -ms-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); - background: -o-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); -} - -form { - margin-bottom: 0; -} - -#inner { - width: 300px; - margin: 0 auto; -} - -input { - font-weight: bold; - font-size: 15px; -} - - -.sort { - cursor: pointer; -} - -.sort:after { - content: '▲▼' -} - -.sort.up:after { - content: '▲' -} - -.sort.down:after { - content: '▼' -} - - -#installed-plugins thead tr th:nth-child(3) { - width: 15%; -} - -table { - border: 1px solid #ddd; - border-radius: 3px; - border-spacing: 0; - width: 100%; - margin: 20px 0; -} - -.table-container { - width: 100%; - overflow: auto; - max-height: 90vh; -} - - -#available-plugins th:first-child, #available-plugins th:nth-child(2) { - text-align: center; -} - -td, th { - padding: 5px; -} - -.template { - display: none; -} - -#installed-plugins td > div { - position: relative; /* Allows us to position the loading indicator relative to this row */ - display: inline-block; /*make this fill the whole cell*/ - width: 100%; -} - -.messages { - height: 5em; -} - -.messages * { - display: none; - text-align: center; -} - -.messages .fetching { - display: block; -} - -.progress { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - padding: auto; - - background: rgb(255, 255, 255); - display: none; -} - -#search-progress.progress { - padding-top: 20%; - background: rgba(255, 255, 255, 0.3); -} - -.progress * { - display: block; - margin: 0 auto; - text-align: center; - color: #666; -} - - -.settings-page { - display: flex; - flex-direction: column; - gap: 20px; - height: 100%; -} - -.settings { - flex-grow: max(1, 1); - outline: none; - width: 100%; - resize: none; - font-family: monospace; -} - -#response { - display: inline; -} - -a:link, a:visited, a:hover, a:focus { - color: #333333; - text-decoration: none; -} - -a:focus, a:hover { - text-decoration: underline; -} - -.installed-results a:link, -.search-results a:link, -.installed-results a:visited, -.search-results a:visited, -.installed-results a:hover, -.search-results a:hover, -.installed-results a:focus, -.search-results a:focus { - text-decoration: underline; -} - -.installed-results a:focus, -.search-results a:focus, -.installed-results a:hover, -.search-results a:hover { - text-decoration: none; -} - -pre { - white-space: pre-wrap; - word-wrap: break-word; -} - - -#icon-button { - color: var(--etherpad-color); - top: 10px; - background-color: transparent; - border: none; - z-index: 99; - position: absolute; - left: 10px; -} - - -.inner-menu span:nth-child(2) { - display: flex; - margin-top: 30px; -} - -#wrapper.closed .menu { - left: calc(-1 * var(--sidebar-width)); -} - -#wrapper.closed .innerwrapper { - margin-left: 0; -} - -@media (max-width: 800px) { - - div.innerwrapper { - margin-left: 0; - } - - .inner-menu { - border-radius: 0; - } - - div.menu { - height: auto; - border-right: none; - --sidebar-width: 100%; - float: left; - } - - table { - border: none; - } - - table, thead, tbody, td, tr { - display: block; - } - - thead tr { - display: none; - } - - tr { - border: 1px solid #ccc; - margin-bottom: 5px; - border-radius: 3px; - } - - td { - border: none; - border-bottom: 1px solid #eee; - position: relative; - padding-left: 50%; - white-space: normal; - text-align: left; - } - - td.name { - word-wrap: break-word; - } - - td:before { - position: absolute; - top: 6px; - left: 6px; - text-align: left; - padding-right: 10px; - white-space: nowrap; - font-weight: bold; - content: attr(data-label); - } - - td:last-child { - border-bottom: none; - } - - table input[type="button"] { - float: none; - } -} - - -.settings-button-bar { - margin-top: 10px; - display: flex; - gap: 10px; -} - -.login-background { - background-image: url("/fond.jpg"); - background-position: center; - background-repeat: no-repeat; - background-size: cover; - display: flex; - justify-content: center; - align-items: center; - height: 100vh; - background-color: #f0f0f0; -} - -.login-inner-box div { - margin-top: 1rem; -} - -.login-inner-box [type=submit] { - margin-top: 2rem; -} - - -.login-textinput { - width: 100%; - padding: 10px; - background-color: #fffacc; - border-radius: 5px; - border: 1px solid #ccc; - margin-bottom: 10px; -} - -.login-box { - padding: 20px; - border-radius: 40px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); - background-color: #fff; -} - - -@media (max-width: 900px) { - .login-box { - width: 90% - } -} - -.login-inner-box { - position: relative; - padding: 20px; -} - -.login-title { - padding: 0; - margin: 0; - text-align: center; - color: var(--etherpad-color); - font-size: 4rem; - font-weight: 1000; -} - -.login-button { - padding: 10px; - background-color: var(--etherpad-color); - color: white; - border: none; - border-radius: 5px; - cursor: pointer; - width: 100%; - height: 40px; -} - -.dialog-overlay { - position: fixed; - inset: 0; - background-color: white; - z-index: 100; -} - - -.dialog-confirm-overlay { - position: fixed; - inset: 0; - background-color: rgba(0, 0, 0, 0.5); - z-index: 100; -} - - -.dialog-confirm-content { - position: fixed; - top: 50%; - left: 50%; - background-color: white; - transform: translate(-50%, -50%); - padding: 20px; - z-index: 101; -} - - -.dialog-content { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - padding: 20px; - z-index: 101; -} - -.dialog-title { - color: var(--etherpad-color); - font-size: 2em; - margin-bottom: 20px; -} - - -.ToastViewport { - position: fixed; - top: 10px; - right: 20px; - display: flex; - flex-direction: column; - gap: 10px; - width: 390px; - max-width: 100vw; - margin: 0; - list-style: none; - z-index: 2147483647; - outline: none; -} - -.ToastRootSuccess { - background-color: lawngreen; -} - -.ToastRootFailure { - background-color: red; -} - -.ToastRootFailure > .ToastTitle { - color: white; -} - -.ToastRoot { - border-radius: 20px; - box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px; - padding: 15px; - display: grid; - grid-template-areas: 'title action' 'description action'; - grid-template-columns: auto max-content; - column-gap: 15px; - align-items: center; -} - -.ToastRoot[data-state='open'] { - animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.ToastRoot[data-state='closed'] { - animation: hide 100ms ease-in; -} - -.ToastRoot[data-swipe='move'] { - transform: translateX(var(--radix-toast-swipe-move-x)); -} - -.ToastRoot[data-swipe='cancel'] { - transform: translateX(0); - transition: transform 200ms ease-out; -} - -.ToastRoot[data-swipe='end'] { - animation: swipeOut 100ms ease-out; -} - -@keyframes hide { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -@keyframes slideIn { - from { - transform: translateX(calc(100% + var(--viewport-padding))); - } - to { - transform: translateX(0); - } -} - -@keyframes swipeOut { - from { - transform: translateX(var(--radix-toast-swipe-end-x)); - } - to { - transform: translateX(calc(100% + var(--viewport-padding))); - } -} - -.ToastTitle { - grid-area: title; - margin-bottom: 5px; - font-weight: 500; - color: var(--slate-12); - padding: 10px; - font-size: 15px; -} - -.ToastDescription { - grid-area: description; - margin: 0; - color: var(--slate-11); - font-size: 13px; - line-height: 1.3; -} - -.ToastAction { - grid-area: action; -} - -.help-block { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 20px -} - -.search-field { - position: relative; -} - -.search-field input { - border-color: transparent; - border-radius: 20px; - height: 2.5rem; - width: 100%; - padding: 5px 5px 5px 30px; -} - -.search-field input:focus { - outline: none; -} - - -.send-message { - position: relative; -} - -.send-message input { - width: auto; -} - -.send-message { -} - -.send-message svg { - position: absolute; - right: 3px; - bottom: -3px; - left: auto !important; -} - -.search-field svg { - position: absolute; - left: 3px; - bottom: -3px; -} - - -.search-field svg { - color: gray -} - -table { - margin: 25px 0; - font-size: 0.9em; - font-family: sans-serif; - min-width: 400px; - box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); -} - -th:first-child { - border-top-left-radius: 10px; -} - -th:last-child { - border-top-right-radius: 10px; -} - -table thead tr { - font-size: 25px; - background-color: var(--etherpad-color); - color: #ffffff; - text-align: left; -} - -table tbody tr { - border-bottom: 1px solid #dddddd; -} - -table tr:nth-child(even) td { - background-color: lightgray; -} - -table tr td { - padding: 12px 15px; -} - -table tbody tr:nth-of-type(even) { - background-color: #f3f3f3; -} - -table tbody tr:last-of-type { - border-bottom: 2px solid #009879; -} - -table tbody tr.active-row { - font-weight: bold; - color: #009879; -} - - -.pad-pagination { - display: flex; - justify-content: center; - gap: 10px; - margin-top: 20px; -} - -.pad-pagination button { - display: flex; - padding: 10px 20px; - border-radius: 5px; - border: none; - color: black; - cursor: pointer; -} - - -.pad-pagination button:disabled { - background: transparent; - color: lightgrey; - cursor: not-allowed; -} - -.pad-pagination span { - align-self: center; -} - -.pad-pagination > span { - font-size: 20px; -} - - -.login-page .login-form .input-control input[type=text], .login-page .login-form .input-control input[type=email], .login-page .login-form .input-control input[type=password], .login-page .signup-form .input-control input[type=text], .login-page .signup-form .input-control input[type=email], .login-page .signup-form .input-control input[type=password], .login-page .forgot-form .input-control input[type=text], .login-page .forgot-form .input-control input[type=email], .login-page .forgot-form .input-control input[type=password] { - width: 100%; - padding: 12px 20px; - margin: 8px 0; - display: inline-block; - border-bottom: 2px solid #ccc; - border-top: 0; - border-left: 0; - border-right: 0; - -webkit-box-sizing: border-box; - box-sizing: border-box; - border-radius: 5px; - font-size: 14px; - color: #666; - background-color: #f8f8f8; - -webkit-transition: all 0.3s ease-in-out; - transition: all 0.3s ease-in-out; -} - -input, button, select, optgroup, textarea { - margin: 0; - font-family: inherit; - font-size: inherit; - line-height: inherit; -} - -.icon-input { - position: relative; -} - -.icon-input svg { - position: absolute; - top: 50%; - transform: translateY(-50%); - right: 10px; - color: #666; -} - - -.SwitchRoot { - align-self: center; - width: 60px; - height: 30px; - background-color: black; - border-radius: 9999px; - position: relative; - box-shadow: 0 2px 10px var(--black-a7); - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -} - -.SwitchRoot:focus { - box-shadow: 0 0 0 2px black; -} - -.SwitchRoot[data-state='checked'] { - background-color: var(--etherpad-color); -} - -.SwitchThumb { - display: block; - width: 20px; - height: 20px; - background-color: white; - border-radius: 9999px; - box-shadow: 0 2px 2px var(--black-a7); - transition: transform 100ms; - transform: translateX(2px); - will-change: transform; -} - -.SwitchThumb[data-state='checked'] { - transform: translateX(25px); -} - -.Label { - color: white; - font-size: 15px; - line-height: 1; -} - -.message { - position: relative; - padding: 10px; - border: 1px solid #e0e0e0; - margin: 10px 20px 10px 10px; - border-radius: 10px 0 10px 10px; - background-color: var(--etherpad-color); - color: white -} - -.search-pads { - text-align: center; -} - -.search-pads-body tr td:last-child { - display: flex; - justify-content: center; -} diff --git a/admin/src/localization/i18n.ts b/admin/src/localization/i18n.ts deleted file mode 100644 index 67ae140e7..000000000 --- a/admin/src/localization/i18n.ts +++ /dev/null @@ -1,57 +0,0 @@ -import i18n from 'i18next' -import {initReactI18next} from "react-i18next"; -import LanguageDetector from 'i18next-browser-languagedetector' - - -import { BackendModule } from 'i18next'; - -const LazyImportPlugin: BackendModule = { - type: 'backend', - init: function () { - }, - read: async function (language, namespace, callback) { - - let baseURL = import.meta.env.BASE_URL - if(namespace === "translation") { - // If default we load the translation file - baseURL+=`/locales/${language}.json` - } else { - // Else we load the former plugin translation file - baseURL+=`/${namespace}/${language}.json` - } - - const localeJSON = await fetch(baseURL, { - cache: "force-cache" - }) - let json; - - try { - json = JSON.parse(await localeJSON.text()) - } catch(e) { - callback(new Error("Error loading"), null); - } - - - callback(null, json); - }, - - save: function () { - }, - - create: function () { - /* save the missing translation */ - }, -}; - -i18n - .use(LanguageDetector) - .use(LazyImportPlugin) - .use(initReactI18next) - .init( - { - ns: ['translation','ep_admin_pads'], - fallbackLng: 'en' - } - ) - -export default i18n diff --git a/admin/src/main.tsx b/admin/src/main.tsx deleted file mode 100644 index 5efc26de6..000000000 --- a/admin/src/main.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' -import {createBrowserRouter, createRoutesFromElements, Route, RouterProvider} from "react-router-dom"; -import {HomePage} from "./pages/HomePage.tsx"; -import {SettingsPage} from "./pages/SettingsPage.tsx"; -import {LoginScreen} from "./pages/LoginScreen.tsx"; -import {HelpPage} from "./pages/HelpPage.tsx"; -import * as Toast from '@radix-ui/react-toast' -import {I18nextProvider} from "react-i18next"; -import i18n from "./localization/i18n.ts"; -import {PadPage} from "./pages/PadPage.tsx"; -import {ToastDialog} from "./utils/Toast.tsx"; -import {ShoutPage} from "./pages/ShoutPage.tsx"; - -const router = createBrowserRouter(createRoutesFromElements( - <>}> - }/> - }/> - }/> - }/> - }/> - }/> - - }/> - -), { - basename: import.meta.env.BASE_URL -}) - - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - , -) diff --git a/admin/src/pages/HelpPage.tsx b/admin/src/pages/HelpPage.tsx deleted file mode 100644 index dd9695b0a..000000000 --- a/admin/src/pages/HelpPage.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import {Trans} from "react-i18next"; -import {useStore} from "../store/store.ts"; -import {useEffect, useState} from "react"; -import {HelpObj} from "./Plugin.ts"; - -export const HelpPage = () => { - const settingsSocket = useStore(state=>state.settingsSocket) - const [helpData, setHelpData] = useState(); - - useEffect(() => { - if(!settingsSocket) return; - settingsSocket?.on('reply:help', (data) => { - setHelpData(data) - }); - - settingsSocket?.emit('help'); - }, [settingsSocket]); - - const renderHooks = (hooks:Record>) => { - return Object.keys(hooks).map((hookName, i) => { - return
-

{hookName}

-
    - {Object.keys(hooks[hookName]).map((hook, i) =>
  • {hook} -
      - {Object.keys(hooks[hookName][hook]).map((subHook, i) =>
    • {subHook}
    • )} -
    -
  • )} -
-
- }) - } - - - if (!helpData) return
- - return
-

-
-
-
{helpData?.epVersion}
-
-
{helpData.latestVersion}
-
Git sha
-
{helpData.gitCommit}
-
-

-
    - {helpData.installedPlugins.map((plugin, i) =>
  • {plugin}
  • )} -
- -

-
    - {helpData.installedParts.map((part, i) =>
  • {part}
  • )} -
- -

- { - renderHooks(helpData.installedServerHooks) - } - -

- - { - renderHooks(helpData.installedClientHooks) - } -

- -
-} diff --git a/admin/src/pages/HomePage.tsx b/admin/src/pages/HomePage.tsx deleted file mode 100644 index f5ce0a5ab..000000000 --- a/admin/src/pages/HomePage.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import {useStore} from "../store/store.ts"; -import {useEffect, useMemo, useState} from "react"; -import {InstalledPlugin, PluginDef, SearchParams} from "./Plugin.ts"; -import {useDebounce} from "../utils/useDebounce.ts"; -import {Trans, useTranslation} from "react-i18next"; -import {SearchField} from "../components/SearchField.tsx"; -import {ArrowUpFromDot, Download, Trash} from "lucide-react"; -import {IconButton} from "../components/IconButton.tsx"; -import {determineSorting} from "../utils/sorting.ts"; - - -export const HomePage = () => { - const pluginsSocket = useStore(state=>state.pluginsSocket) - const [plugins,setPlugins] = useState([]) - const installedPlugins = useStore(state=>state.installedPlugins) - const setInstalledPlugins = useStore(state=>state.setInstalledPlugins) - const [searchParams, setSearchParams] = useState({ - offset: 0, - limit: 99999, - sortBy: 'name', - sortDir: 'asc', - searchTerm: '' - }) - - const filteredInstallablePlugins = useMemo(()=>{ - return plugins.sort((a, b)=>{ - if(searchParams.sortBy === "version"){ - if(searchParams.sortDir === "asc"){ - return a.version.localeCompare(b.version) - } - return b.version.localeCompare(a.version) - } - - if(searchParams.sortBy === "last-updated"){ - if(searchParams.sortDir === "asc"){ - return a.time.localeCompare(b.time) - } - return b.time.localeCompare(a.time) - } - - - if (searchParams.sortBy === "name") { - if(searchParams.sortDir === "asc"){ - return a.name.localeCompare(b.name) - } - return b.name.localeCompare(a.name) - } - return 0 - }) - }, [plugins, searchParams]) - - const sortedInstalledPlugins = useMemo(()=>{ - return useStore.getState().installedPlugins.sort((a, b)=>{ - - if(a.name < b.name){ - return -1 - } - if(a.name > b.name){ - return 1 - } - return 0 - }) - - } ,[installedPlugins, searchParams]) - - const [searchTerm, setSearchTerm] = useState('') - const {t} = useTranslation() - - - useEffect(() => { - if(!pluginsSocket){ - return - } - - pluginsSocket.on('results:installed', (data:{ - installed: InstalledPlugin[] - })=>{ - setInstalledPlugins(data.installed) - }) - - pluginsSocket.on('results:updatable', (data) => { - const newInstalledPlugins = useStore.getState().installedPlugins.map(plugin => { - if (data.updatable.includes(plugin.name)) { - return { - ...plugin, - updatable: true - } - } - return plugin - }) - setInstalledPlugins(newInstalledPlugins) - }) - - pluginsSocket.on('finished:install', () => { - pluginsSocket!.emit('getInstalled'); - }) - - pluginsSocket.on('finished:uninstall', () => { - console.log("Finished uninstall") - }) - - - // Reload on reconnect - pluginsSocket.on('connect', ()=>{ - // Initial retrieval of installed plugins - pluginsSocket.emit('getInstalled'); - pluginsSocket.emit('search', searchParams) - }) - - pluginsSocket.emit('getInstalled'); - - // check for updates every 5mins - const interval = setInterval(() => { - pluginsSocket.emit('checkUpdates'); - }, 1000 * 60 * 5); - - return ()=>{ - clearInterval(interval) - } - }, [pluginsSocket]); - - - useEffect(() => { - if (!pluginsSocket) { - return - } - pluginsSocket?.emit('search', searchParams) - pluginsSocket!.on('results:search', (data: { - results: PluginDef[] - }) => { - setPlugins(data.results) - }) - pluginsSocket!.on('results:searcherror', (data: {error: string}) => { - console.log(data.error) - useStore.getState().setToastState({ - open: true, - title: "Error retrieving plugins", - success: false - }) - }) - }, [searchParams, pluginsSocket]); - - const uninstallPlugin = (pluginName: string)=>{ - pluginsSocket!.emit('uninstall', pluginName); - // Remove plugin - setInstalledPlugins(installedPlugins.filter(i=>i.name !== pluginName)) - } - - const installPlugin = (pluginName: string)=>{ - pluginsSocket!.emit('install', pluginName); - setPlugins(plugins.filter(plugin=>plugin.name !== pluginName)) - } - - useDebounce(()=>{ - setSearchParams({ - ...searchParams, - offset: 0, - searchTerm: searchTerm - }) - }, 500, [searchTerm]) - - - return
-

- -

- - - - - - - - - - - {sortedInstalledPlugins.map((plugin, index) => { - return - - - - - })} - -
{plugin.name}{plugin.version} - { - plugin.updatable ? - installPlugin(plugin.name)} icon={} title="Update"> - : } title={} onClick={() => uninstallPlugin(plugin.name)}/> - } -
- - -

- {setSearchTerm(v.target.value)}} placeholder={t('admin_plugins.available_search.placeholder')} value={searchTerm}/> - -
- - - - - - - - - - - - {(filteredInstallablePlugins.length > 0) ? - filteredInstallablePlugins.map((plugin) => { - return - - - - - - - }) - : - - } - -
{ - setSearchParams({ - ...searchParams, - sortBy: 'name', - sortDir: searchParams.sortDir === "asc"? "desc": "asc" - }) - }}> - { - setSearchParams({ - ...searchParams, - sortBy: 'version', - sortDir: searchParams.sortDir === "asc"? "desc": "asc" - }) - }}>{ - setSearchParams({ - ...searchParams, - sortBy: 'last-updated', - sortDir: searchParams.sortDir === "asc"? "desc": "asc" - }) - }}>
{plugin.name}{plugin.description}{plugin.version}{plugin.time} - } onClick={() => installPlugin(plugin.name)} title={}/> -
{searchTerm == '' ? : }
-
-
-} diff --git a/admin/src/pages/LoginScreen.tsx b/admin/src/pages/LoginScreen.tsx deleted file mode 100644 index 61ac8993e..000000000 --- a/admin/src/pages/LoginScreen.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import {useStore} from "../store/store.ts"; -import {useNavigate} from "react-router-dom"; -import {SubmitHandler, useForm} from "react-hook-form"; -import {Eye, EyeOff} from "lucide-react"; -import {useState} from "react"; - -type Inputs = { - username: string - password: string -} - -export const LoginScreen = ()=>{ - const navigate = useNavigate() - const [passwordVisible, setPasswordVisible] = useState(false) - - const { - register, - handleSubmit} = useForm() - - const login: SubmitHandler = ({username,password})=>{ - fetch('/admin-auth/', { - method: 'POST', - headers:{ - Authorization: `Basic ${btoa(`${username}:${password}`)}` - } - }).then(r=>{ - if(!r.ok) { - useStore.getState().setToastState({ - open: true, - title: "Login failed", - success: false - }) - } else { - navigate('/') - } - }).catch(e=>{ - console.error(e) - }) - } - - return
-
-

Etherpad

-
-
Username
- -
Password
- - - {passwordVisible? setPasswordVisible(!passwordVisible)}/> : - setPasswordVisible(!passwordVisible)}/>} - - -
-
-
-} diff --git a/admin/src/pages/PadPage.tsx b/admin/src/pages/PadPage.tsx deleted file mode 100644 index b5db854f5..000000000 --- a/admin/src/pages/PadPage.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import {Trans, useTranslation} from "react-i18next"; -import {useEffect, useMemo, useState} from "react"; -import {useStore} from "../store/store.ts"; -import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts"; -import {useDebounce} from "../utils/useDebounce.ts"; -import {determineSorting} from "../utils/sorting.ts"; -import * as Dialog from "@radix-ui/react-dialog"; -import {IconButton} from "../components/IconButton.tsx"; -import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack} from "lucide-react"; -import {SearchField} from "../components/SearchField.tsx"; - -export const PadPage = ()=>{ - const settingsSocket = useStore(state=>state.settingsSocket) - const [searchParams, setSearchParams] = useState({ - offset: 0, - limit: 12, - pattern: '', - sortBy: 'padName', - ascending: true - }) - const {t} = useTranslation() - const [searchTerm, setSearchTerm] = useState('') - const pads = useStore(state=>state.pads) - const [currentPage, setCurrentPage] = useState(0) - const [deleteDialog, setDeleteDialog] = useState(false) - const [errorText, setErrorText] = useState(null) - const [padToDelete, setPadToDelete] = useState('') - const pages = useMemo(()=>{ - if(!pads){ - return 0; - } - - return Math.ceil(pads!.total / searchParams.limit) - },[pads, searchParams.limit]) - - useDebounce(()=>{ - setSearchParams({ - ...searchParams, - pattern: searchTerm - }) - - }, 500, [searchTerm]) - - useEffect(() => { - if(!settingsSocket){ - return - } - - settingsSocket.emit('padLoad', searchParams) - - }, [settingsSocket, searchParams]); - - useEffect(() => { - if(!settingsSocket){ - return - } - - settingsSocket.on('results:padLoad', (data: PadSearchResult)=>{ - useStore.getState().setPads(data); - }) - - - settingsSocket.on('results:deletePad', (padID: string)=>{ - const newPads = useStore.getState().pads?.results?.filter((pad)=>{ - return pad.padName !== padID - }) - useStore.getState().setPads({ - total: useStore.getState().pads!.total-1, - results: newPads - }) - }) - - settingsSocket.on('results:cleanupPadRevisions', (data)=>{ - let newPads = useStore.getState().pads?.results ?? [] - - if (data.error) { - setErrorText(data.error) - return - } - - newPads.forEach((pad)=>{ - if (pad.padName === data.padId) { - pad.revisionNumber = data.keepRevisions - } - }) - - useStore.getState().setPads({ - results: newPads, - total: useStore.getState().pads!.total - }) - }) - }, [settingsSocket, pads]); - - const deletePad = (padID: string)=>{ - settingsSocket?.emit('deletePad', padID) - } - - const cleanupPad = (padID: string)=>{ - settingsSocket?.emit('cleanupPadRevisions', padID) - } - - - return
- - - -
-
-
- {t("ep_admin_pads:ep_adminpads2_confirm", { - padID: padToDelete, - })} -
-
- - -
-
-
-
-
- - - - -
-
Error occured: {errorText}
-
- -
-
-
-
-
-

- setSearchTerm(v.target.value)} placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/> - - - - - - - - - - - - { - pads?.results?.map((pad)=>{ - return - - - - - - - }) - } - -
{ - setSearchParams({ - ...searchParams, - sortBy: 'padName', - ascending: !searchParams.ascending - }) - }}>{ - setSearchParams({ - ...searchParams, - sortBy: 'userCount', - ascending: !searchParams.ascending - }) - }}>{ - setSearchParams({ - ...searchParams, - sortBy: 'lastEdited', - ascending: !searchParams.ascending - }) - }}>{ - setSearchParams({ - ...searchParams, - sortBy: 'revisionNumber', - ascending: !searchParams.ascending - }) - }}>Revision number
{pad.padName}{pad.userCount}{new Date(pad.lastEdited).toLocaleString()}{pad.revisionNumber} -
- } title={} onClick={()=>{ - setPadToDelete(pad.padName) - setDeleteDialog(true) - }}/> - } title={} onClick={()=>{ - cleanupPad(pad.padName) - }}/> - } title="view" onClick={()=>window.open(`/p/${pad.padName}`, '_blank')}/> -
-
-
- - {currentPage+1} out of {pages} - -
-
-} diff --git a/admin/src/pages/Plugin.ts b/admin/src/pages/Plugin.ts deleted file mode 100644 index f5563863b..000000000 --- a/admin/src/pages/Plugin.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type PluginDef = { - name: string, - description: string, - version: string, - time: string, - official: boolean, -} - - -export type InstalledPlugin = { - name: string, - path: string, - realPath: string, - version:string, - updatable?: boolean -} - - -export type SearchParams = { - searchTerm: string, - offset: number, - limit: number, - sortBy: 'name'|'version'|'last-updated', - sortDir: 'asc'|'desc' -} - - -export type HelpObj = { - epVersion: string - gitCommit: string - installedClientHooks: Record>, - installedParts: string[], - installedPlugins: string[], - installedServerHooks: Record, - latestVersion: string -} diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx deleted file mode 100644 index f781f67e1..000000000 --- a/admin/src/pages/SettingsPage.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import {useStore} from "../store/store.ts"; -import {isJSONClean, cleanComments} from "../utils/utils.ts"; -import {Trans} from "react-i18next"; -import {IconButton} from "../components/IconButton.tsx"; -import {RotateCw, Save} from "lucide-react"; - -export const SettingsPage = ()=>{ - const settingsSocket = useStore(state=>state.settingsSocket) - const settings = cleanComments(useStore(state=>state.settings)) - - return
-

- "; + support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; +} )(); +var documentElement = document.documentElement; + + + +var + rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|pointer|contextmenu|drag|drop)|click/, + rtypenamespace = /^([^.]*)(?:\.(.+)|)/; + +function returnTrue() { + return true; +} + +function returnFalse() { + return false; +} + +// Support: IE <=9 only +// See #13393 for more info +function safeActiveElement() { + try { + return document.activeElement; + } catch ( err ) { } +} + +function on( elem, types, selector, data, fn, one ) { + var origFn, type; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { + + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + on( elem, type, selector, data, types[ type ], one ); + } + return elem; + } + + if ( data == null && fn == null ) { + + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return elem; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return elem.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + } ); +} + +/* + * Helper functions for managing events -- not part of the public interface. + * Props to Dean Edwards' addEvent library for many of the ideas. + */ +jQuery.event = { + + global: {}, + + add: function( elem, types, handler, data, selector ) { + + var handleObjIn, eventHandle, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = dataPriv.get( elem ); + + // Don't attach events to noData or text/comment nodes (but allow plain objects) + if ( !elemData ) { + return; + } + + // Caller can pass in an object of custom data in lieu of the handler + if ( handler.handler ) { + handleObjIn = handler; + handler = handleObjIn.handler; + selector = handleObjIn.selector; + } + + // Ensure that invalid selectors throw exceptions at attach time + // Evaluate against documentElement in case elem is a non-element node (e.g., document) + if ( selector ) { + jQuery.find.matchesSelector( documentElement, selector ); + } + + // Make sure that the handler has a unique ID, used to find/remove it later + if ( !handler.guid ) { + handler.guid = jQuery.guid++; + } + + // Init the element's event structure and main handler, if this is the first + if ( !( events = elemData.events ) ) { + events = elemData.events = {}; + } + if ( !( eventHandle = elemData.handle ) ) { + eventHandle = elemData.handle = function( e ) { + + // Discard the second event of a jQuery.event.trigger() and + // when an event is called after a page has unloaded + return typeof jQuery !== "undefined" && jQuery.event.triggered !== e.type ? + jQuery.event.dispatch.apply( elem, arguments ) : undefined; + }; + } + + // Handle multiple events separated by a space + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // There *must* be a type, no attaching namespace-only handlers + if ( !type ) { + continue; + } + + // If event changes its type, use the special event handlers for the changed type + special = jQuery.event.special[ type ] || {}; + + // If selector defined, determine special event api type, otherwise given type + type = ( selector ? special.delegateType : special.bindType ) || type; + + // Update special based on newly reset type + special = jQuery.event.special[ type ] || {}; + + // handleObj is passed to all event handlers + handleObj = jQuery.extend( { + type: type, + origType: origType, + data: data, + handler: handler, + guid: handler.guid, + selector: selector, + needsContext: selector && jQuery.expr.match.needsContext.test( selector ), + namespace: namespaces.join( "." ) + }, handleObjIn ); + + // Init the event handler queue if we're the first + if ( !( handlers = events[ type ] ) ) { + handlers = events[ type ] = []; + handlers.delegateCount = 0; + + // Only use addEventListener if the special events handler returns false + if ( !special.setup || + special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + + if ( elem.addEventListener ) { + elem.addEventListener( type, eventHandle ); + } + } + } + + if ( special.add ) { + special.add.call( elem, handleObj ); + + if ( !handleObj.handler.guid ) { + handleObj.handler.guid = handler.guid; + } + } + + // Add to the element's handler list, delegates in front + if ( selector ) { + handlers.splice( handlers.delegateCount++, 0, handleObj ); + } else { + handlers.push( handleObj ); + } + + // Keep track of which events have ever been used, for event optimization + jQuery.event.global[ type ] = true; + } + + }, + + // Detach an event or set of events from an element + remove: function( elem, types, handler, selector, mappedTypes ) { + + var j, origCount, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = dataPriv.hasData( elem ) && dataPriv.get( elem ); + + if ( !elemData || !( events = elemData.events ) ) { + return; + } + + // Once for each type.namespace in types; type may be omitted + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // Unbind all events (on this namespace, if provided) for the element + if ( !type ) { + for ( type in events ) { + jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); + } + continue; + } + + special = jQuery.event.special[ type ] || {}; + type = ( selector ? special.delegateType : special.bindType ) || type; + handlers = events[ type ] || []; + tmp = tmp[ 2 ] && + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ); + + // Remove matching events + origCount = j = handlers.length; + while ( j-- ) { + handleObj = handlers[ j ]; + + if ( ( mappedTypes || origType === handleObj.origType ) && + ( !handler || handler.guid === handleObj.guid ) && + ( !tmp || tmp.test( handleObj.namespace ) ) && + ( !selector || selector === handleObj.selector || + selector === "**" && handleObj.selector ) ) { + handlers.splice( j, 1 ); + + if ( handleObj.selector ) { + handlers.delegateCount--; + } + if ( special.remove ) { + special.remove.call( elem, handleObj ); + } + } + } + + // Remove generic event handler if we removed something and no more handlers exist + // (avoids potential for endless recursion during removal of special event handlers) + if ( origCount && !handlers.length ) { + if ( !special.teardown || + special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + + jQuery.removeEvent( elem, type, elemData.handle ); + } + + delete events[ type ]; + } + } + + // Remove data and the expando if it's no longer used + if ( jQuery.isEmptyObject( events ) ) { + dataPriv.remove( elem, "handle events" ); + } + }, + + dispatch: function( nativeEvent ) { + + // Make a writable jQuery.Event from the native event object + var event = jQuery.event.fix( nativeEvent ); + + var i, j, ret, matched, handleObj, handlerQueue, + args = new Array( arguments.length ), + handlers = ( dataPriv.get( this, "events" ) || {} )[ event.type ] || [], + special = jQuery.event.special[ event.type ] || {}; + + // Use the fix-ed jQuery.Event rather than the (read-only) native event + args[ 0 ] = event; + + for ( i = 1; i < arguments.length; i++ ) { + args[ i ] = arguments[ i ]; + } + + event.delegateTarget = this; + + // Call the preDispatch hook for the mapped type, and let it bail if desired + if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { + return; + } + + // Determine handlers + handlerQueue = jQuery.event.handlers.call( this, event, handlers ); + + // Run delegates first; they may want to stop propagation beneath us + i = 0; + while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) { + event.currentTarget = matched.elem; + + j = 0; + while ( ( handleObj = matched.handlers[ j++ ] ) && + !event.isImmediatePropagationStopped() ) { + + // Triggered event must either 1) have no namespace, or 2) have namespace(s) + // a subset or equal to those in the bound event (both can have no namespace). + if ( !event.rnamespace || event.rnamespace.test( handleObj.namespace ) ) { + + event.handleObj = handleObj; + event.data = handleObj.data; + + ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle || + handleObj.handler ).apply( matched.elem, args ); + + if ( ret !== undefined ) { + if ( ( event.result = ret ) === false ) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + } + + // Call the postDispatch hook for the mapped type + if ( special.postDispatch ) { + special.postDispatch.call( this, event ); + } + + return event.result; + }, + + handlers: function( event, handlers ) { + var i, matches, sel, handleObj, + handlerQueue = [], + delegateCount = handlers.delegateCount, + cur = event.target; + + // Support: IE <=9 + // Find delegate handlers + // Black-hole SVG instance trees (#13180) + // + // Support: Firefox <=42 + // Avoid non-left-click in FF but don't block IE radio events (#3861, gh-2343) + if ( delegateCount && cur.nodeType && + ( event.type !== "click" || isNaN( event.button ) || event.button < 1 ) ) { + + for ( ; cur !== this; cur = cur.parentNode || this ) { + + // Don't check non-elements (#13208) + // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) + if ( cur.nodeType === 1 && ( cur.disabled !== true || event.type !== "click" ) ) { + matches = []; + for ( i = 0; i < delegateCount; i++ ) { + handleObj = handlers[ i ]; + + // Don't conflict with Object.prototype properties (#13203) + sel = handleObj.selector + " "; + + if ( matches[ sel ] === undefined ) { + matches[ sel ] = handleObj.needsContext ? + jQuery( sel, this ).index( cur ) > -1 : + jQuery.find( sel, this, null, [ cur ] ).length; + } + if ( matches[ sel ] ) { + matches.push( handleObj ); + } + } + if ( matches.length ) { + handlerQueue.push( { elem: cur, handlers: matches } ); + } + } + } + } + + // Add the remaining (directly-bound) handlers + if ( delegateCount < handlers.length ) { + handlerQueue.push( { elem: this, handlers: handlers.slice( delegateCount ) } ); + } + + return handlerQueue; + }, + + addProp: function( name, hook ) { + Object.defineProperty( jQuery.Event.prototype, name, { + enumerable: true, + configurable: true, + + get: jQuery.isFunction( hook ) ? + function() { + if ( this.originalEvent ) { + return hook( this.originalEvent ); + } + } : + function() { + if ( this.originalEvent ) { + return this.originalEvent[ name ]; + } + }, + + set: function( value ) { + Object.defineProperty( this, name, { + enumerable: true, + configurable: true, + writable: true, + value: value + } ); + } + } ); + }, + + fix: function( originalEvent ) { + return originalEvent[ jQuery.expando ] ? + originalEvent : + new jQuery.Event( originalEvent ); + }, + + special: { + load: { + + // Prevent triggered image.load events from bubbling to window.load + noBubble: true + }, + focus: { + + // Fire native event if possible so blur/focus sequence is correct + trigger: function() { + if ( this !== safeActiveElement() && this.focus ) { + this.focus(); + return false; + } + }, + delegateType: "focusin" + }, + blur: { + trigger: function() { + if ( this === safeActiveElement() && this.blur ) { + this.blur(); + return false; + } + }, + delegateType: "focusout" + }, + click: { + + // For checkbox, fire native event so checked state will be right + trigger: function() { + if ( this.type === "checkbox" && this.click && jQuery.nodeName( this, "input" ) ) { + this.click(); + return false; + } + }, + + // For cross-browser consistency, don't fire native .click() on links + _default: function( event ) { + return jQuery.nodeName( event.target, "a" ); + } + }, + + beforeunload: { + postDispatch: function( event ) { + + // Support: Firefox 20+ + // Firefox doesn't alert if the returnValue field is not set. + if ( event.result !== undefined && event.originalEvent ) { + event.originalEvent.returnValue = event.result; + } + } + } + } +}; + +jQuery.removeEvent = function( elem, type, handle ) { + + // This "if" is needed for plain objects + if ( elem.removeEventListener ) { + elem.removeEventListener( type, handle ); + } +}; + +jQuery.Event = function( src, props ) { + + // Allow instantiation without the 'new' keyword + if ( !( this instanceof jQuery.Event ) ) { + return new jQuery.Event( src, props ); + } + + // Event object + if ( src && src.type ) { + this.originalEvent = src; + this.type = src.type; + + // Events bubbling up the document may have been marked as prevented + // by a handler lower down the tree; reflect the correct value. + this.isDefaultPrevented = src.defaultPrevented || + src.defaultPrevented === undefined && + + // Support: Android <=2.3 only + src.returnValue === false ? + returnTrue : + returnFalse; + + // Create target properties + // Support: Safari <=6 - 7 only + // Target should not be a text node (#504, #13143) + this.target = ( src.target && src.target.nodeType === 3 ) ? + src.target.parentNode : + src.target; + + this.currentTarget = src.currentTarget; + this.relatedTarget = src.relatedTarget; + + // Event type + } else { + this.type = src; + } + + // Put explicitly provided properties onto the event object + if ( props ) { + jQuery.extend( this, props ); + } + + // Create a timestamp if incoming event doesn't have one + this.timeStamp = src && src.timeStamp || jQuery.now(); + + // Mark it as fixed + this[ jQuery.expando ] = true; +}; + +// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding +// https://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html +jQuery.Event.prototype = { + constructor: jQuery.Event, + isDefaultPrevented: returnFalse, + isPropagationStopped: returnFalse, + isImmediatePropagationStopped: returnFalse, + isSimulated: false, + + preventDefault: function() { + var e = this.originalEvent; + + this.isDefaultPrevented = returnTrue; + + if ( e && !this.isSimulated ) { + e.preventDefault(); + } + }, + stopPropagation: function() { + var e = this.originalEvent; + + this.isPropagationStopped = returnTrue; + + if ( e && !this.isSimulated ) { + e.stopPropagation(); + } + }, + stopImmediatePropagation: function() { + var e = this.originalEvent; + + this.isImmediatePropagationStopped = returnTrue; + + if ( e && !this.isSimulated ) { + e.stopImmediatePropagation(); + } + + this.stopPropagation(); + } +}; + +// Includes all common event props including KeyEvent and MouseEvent specific props +jQuery.each( { + altKey: true, + bubbles: true, + cancelable: true, + changedTouches: true, + ctrlKey: true, + detail: true, + eventPhase: true, + metaKey: true, + pageX: true, + pageY: true, + shiftKey: true, + view: true, + "char": true, + charCode: true, + key: true, + keyCode: true, + button: true, + buttons: true, + clientX: true, + clientY: true, + offsetX: true, + offsetY: true, + pointerId: true, + pointerType: true, + screenX: true, + screenY: true, + targetTouches: true, + toElement: true, + touches: true, + + which: function( event ) { + var button = event.button; + + // Add which for key events + if ( event.which == null && rkeyEvent.test( event.type ) ) { + return event.charCode != null ? event.charCode : event.keyCode; + } + + // Add which for click: 1 === left; 2 === middle; 3 === right + if ( !event.which && button !== undefined && rmouseEvent.test( event.type ) ) { + return ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); + } + + return event.which; + } +}, jQuery.event.addProp ); + +// Create mouseenter/leave events using mouseover/out and event-time checks +// so that event delegation works in jQuery. +// Do the same for pointerenter/pointerleave and pointerover/pointerout +// +// Support: Safari 7 only +// Safari sends mouseenter too often; see: +// https://bugs.chromium.org/p/chromium/issues/detail?id=470258 +// for the description of the bug (it existed in older Chrome versions as well). +jQuery.each( { + mouseenter: "mouseover", + mouseleave: "mouseout", + pointerenter: "pointerover", + pointerleave: "pointerout" +}, function( orig, fix ) { + jQuery.event.special[ orig ] = { + delegateType: fix, + bindType: fix, + + handle: function( event ) { + var ret, + target = this, + related = event.relatedTarget, + handleObj = event.handleObj; + + // For mouseenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || ( related !== target && !jQuery.contains( target, related ) ) ) { + event.type = handleObj.origType; + ret = handleObj.handler.apply( this, arguments ); + event.type = fix; + } + return ret; + } + }; +} ); + +jQuery.fn.extend( { + + on: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn ); + }, + one: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn, 1 ); + }, + off: function( types, selector, fn ) { + var handleObj, type; + if ( types && types.preventDefault && types.handleObj ) { + + // ( event ) dispatched jQuery.Event + handleObj = types.handleObj; + jQuery( types.delegateTarget ).off( + handleObj.namespace ? + handleObj.origType + "." + handleObj.namespace : + handleObj.origType, + handleObj.selector, + handleObj.handler + ); + return this; + } + if ( typeof types === "object" ) { + + // ( types-object [, selector] ) + for ( type in types ) { + this.off( type, selector, types[ type ] ); + } + return this; + } + if ( selector === false || typeof selector === "function" ) { + + // ( types [, fn] ) + fn = selector; + selector = undefined; + } + if ( fn === false ) { + fn = returnFalse; + } + return this.each( function() { + jQuery.event.remove( this, types, fn, selector ); + } ); + } +} ); + + +var + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([a-z][^\/\0>\x20\t\r\n\f]*)[^>]*)\/>/gi, + + // Support: IE <=10 - 11, Edge 12 - 13 + // In IE/Edge using regex groups here causes severe slowdowns. + // See https://connect.microsoft.com/IE/feedback/details/1736512/ + rnoInnerhtml = /\s*$/g; + +function manipulationTarget( elem, content ) { + if ( jQuery.nodeName( elem, "table" ) && + jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ) { + + return elem.getElementsByTagName( "tbody" )[ 0 ] || elem; + } + + return elem; +} + +// Replace/restore the type attribute of script elements for safe DOM manipulation +function disableScript( elem ) { + elem.type = ( elem.getAttribute( "type" ) !== null ) + "/" + elem.type; + return elem; +} +function restoreScript( elem ) { + var match = rscriptTypeMasked.exec( elem.type ); + + if ( match ) { + elem.type = match[ 1 ]; + } else { + elem.removeAttribute( "type" ); + } + + return elem; +} + +function cloneCopyEvent( src, dest ) { + var i, l, type, pdataOld, pdataCur, udataOld, udataCur, events; + + if ( dest.nodeType !== 1 ) { + return; + } + + // 1. Copy private data: events, handlers, etc. + if ( dataPriv.hasData( src ) ) { + pdataOld = dataPriv.access( src ); + pdataCur = dataPriv.set( dest, pdataOld ); + events = pdataOld.events; + + if ( events ) { + delete pdataCur.handle; + pdataCur.events = {}; + + for ( type in events ) { + for ( i = 0, l = events[ type ].length; i < l; i++ ) { + jQuery.event.add( dest, type, events[ type ][ i ] ); + } + } + } + } + + // 2. Copy user data + if ( dataUser.hasData( src ) ) { + udataOld = dataUser.access( src ); + udataCur = jQuery.extend( {}, udataOld ); + + dataUser.set( dest, udataCur ); + } +} + +// Fix IE bugs, see support tests +function fixInput( src, dest ) { + var nodeName = dest.nodeName.toLowerCase(); + + // Fails to persist the checked state of a cloned checkbox or radio button. + if ( nodeName === "input" && rcheckableType.test( src.type ) ) { + dest.checked = src.checked; + + // Fails to return the selected option to the default selected state when cloning options + } else if ( nodeName === "input" || nodeName === "textarea" ) { + dest.defaultValue = src.defaultValue; + } +} + +function domManip( collection, args, callback, ignored ) { + + // Flatten any nested arrays + args = concat.apply( [], args ); + + var fragment, first, scripts, hasScripts, node, doc, + i = 0, + l = collection.length, + iNoClone = l - 1, + value = args[ 0 ], + isFunction = jQuery.isFunction( value ); + + // We can't cloneNode fragments that contain checked, in WebKit + if ( isFunction || + ( l > 1 && typeof value === "string" && + !support.checkClone && rchecked.test( value ) ) ) { + return collection.each( function( index ) { + var self = collection.eq( index ); + if ( isFunction ) { + args[ 0 ] = value.call( this, index, self.html() ); + } + domManip( self, args, callback, ignored ); + } ); + } + + if ( l ) { + fragment = buildFragment( args, collection[ 0 ].ownerDocument, false, collection, ignored ); + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + // Require either new content or an interest in ignored elements to invoke the callback + if ( first || ignored ) { + scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); + hasScripts = scripts.length; + + // Use the original fragment for the last item + // instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + for ( ; i < l; i++ ) { + node = fragment; + + if ( i !== iNoClone ) { + node = jQuery.clone( node, true, true ); + + // Keep references to cloned scripts for later restoration + if ( hasScripts ) { + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( scripts, getAll( node, "script" ) ); + } + } + + callback.call( collection[ i ], node, i ); + } + + if ( hasScripts ) { + doc = scripts[ scripts.length - 1 ].ownerDocument; + + // Reenable scripts + jQuery.map( scripts, restoreScript ); + + // Evaluate executable scripts on first document insertion + for ( i = 0; i < hasScripts; i++ ) { + node = scripts[ i ]; + if ( rscriptType.test( node.type || "" ) && + !dataPriv.access( node, "globalEval" ) && + jQuery.contains( doc, node ) ) { + + if ( node.src ) { + + // Optional AJAX dependency, but won't run scripts if not present + if ( jQuery._evalUrl ) { + jQuery._evalUrl( node.src ); + } + } else { + DOMEval( node.textContent.replace( rcleanScript, "" ), doc ); + } + } + } + } + } + } + + return collection; +} + +function remove( elem, selector, keepData ) { + var node, + nodes = selector ? jQuery.filter( selector, elem ) : elem, + i = 0; + + for ( ; ( node = nodes[ i ] ) != null; i++ ) { + if ( !keepData && node.nodeType === 1 ) { + jQuery.cleanData( getAll( node ) ); + } + + if ( node.parentNode ) { + if ( keepData && jQuery.contains( node.ownerDocument, node ) ) { + setGlobalEval( getAll( node, "script" ) ); + } + node.parentNode.removeChild( node ); + } + } + + return elem; +} + +jQuery.extend( { + htmlPrefilter: function( html ) { + return html.replace( rxhtmlTag, "<$1>" ); + }, + + clone: function( elem, dataAndEvents, deepDataAndEvents ) { + var i, l, srcElements, destElements, + clone = elem.cloneNode( true ), + inPage = jQuery.contains( elem.ownerDocument, elem ); + + // Fix IE cloning issues + if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) && + !jQuery.isXMLDoc( elem ) ) { + + // We eschew Sizzle here for performance reasons: https://jsperf.com/getall-vs-sizzle/2 + destElements = getAll( clone ); + srcElements = getAll( elem ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + fixInput( srcElements[ i ], destElements[ i ] ); + } + } + + // Copy the events from the original to the clone + if ( dataAndEvents ) { + if ( deepDataAndEvents ) { + srcElements = srcElements || getAll( elem ); + destElements = destElements || getAll( clone ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + cloneCopyEvent( srcElements[ i ], destElements[ i ] ); + } + } else { + cloneCopyEvent( elem, clone ); + } + } + + // Preserve script evaluation history + destElements = getAll( clone, "script" ); + if ( destElements.length > 0 ) { + setGlobalEval( destElements, !inPage && getAll( elem, "script" ) ); + } + + // Return the cloned set + return clone; + }, + + cleanData: function( elems ) { + var data, elem, type, + special = jQuery.event.special, + i = 0; + + for ( ; ( elem = elems[ i ] ) !== undefined; i++ ) { + if ( acceptData( elem ) ) { + if ( ( data = elem[ dataPriv.expando ] ) ) { + if ( data.events ) { + for ( type in data.events ) { + if ( special[ type ] ) { + jQuery.event.remove( elem, type ); + + // This is a shortcut to avoid jQuery.event.remove's overhead + } else { + jQuery.removeEvent( elem, type, data.handle ); + } + } + } + + // Support: Chrome <=35 - 45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataPriv.expando ] = undefined; + } + if ( elem[ dataUser.expando ] ) { + + // Support: Chrome <=35 - 45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataUser.expando ] = undefined; + } + } + } + } +} ); + +jQuery.fn.extend( { + detach: function( selector ) { + return remove( this, selector, true ); + }, + + remove: function( selector ) { + return remove( this, selector ); + }, + + text: function( value ) { + return access( this, function( value ) { + return value === undefined ? + jQuery.text( this ) : + this.empty().each( function() { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + this.textContent = value; + } + } ); + }, null, value, arguments.length ); + }, + + append: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.appendChild( elem ); + } + } ); + }, + + prepend: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.insertBefore( elem, target.firstChild ); + } + } ); + }, + + before: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this ); + } + } ); + }, + + after: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this.nextSibling ); + } + } ); + }, + + empty: function() { + var elem, + i = 0; + + for ( ; ( elem = this[ i ] ) != null; i++ ) { + if ( elem.nodeType === 1 ) { + + // Prevent memory leaks + jQuery.cleanData( getAll( elem, false ) ); + + // Remove any remaining nodes + elem.textContent = ""; + } + } + + return this; + }, + + clone: function( dataAndEvents, deepDataAndEvents ) { + dataAndEvents = dataAndEvents == null ? false : dataAndEvents; + deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; + + return this.map( function() { + return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); + } ); + }, + + html: function( value ) { + return access( this, function( value ) { + var elem = this[ 0 ] || {}, + i = 0, + l = this.length; + + if ( value === undefined && elem.nodeType === 1 ) { + return elem.innerHTML; + } + + // See if we can take a shortcut and just use innerHTML + if ( typeof value === "string" && !rnoInnerhtml.test( value ) && + !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) { + + value = jQuery.htmlPrefilter( value ); + + try { + for ( ; i < l; i++ ) { + elem = this[ i ] || {}; + + // Remove element nodes and prevent memory leaks + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + elem.innerHTML = value; + } + } + + elem = 0; + + // If using innerHTML throws an exception, use the fallback method + } catch ( e ) {} + } + + if ( elem ) { + this.empty().append( value ); + } + }, null, value, arguments.length ); + }, + + replaceWith: function() { + var ignored = []; + + // Make the changes, replacing each non-ignored context element with the new content + return domManip( this, arguments, function( elem ) { + var parent = this.parentNode; + + if ( jQuery.inArray( this, ignored ) < 0 ) { + jQuery.cleanData( getAll( this ) ); + if ( parent ) { + parent.replaceChild( elem, this ); + } + } + + // Force callback invocation + }, ignored ); + } +} ); + +jQuery.each( { + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" +}, function( name, original ) { + jQuery.fn[ name ] = function( selector ) { + var elems, + ret = [], + insert = jQuery( selector ), + last = insert.length - 1, + i = 0; + + for ( ; i <= last; i++ ) { + elems = i === last ? this : this.clone( true ); + jQuery( insert[ i ] )[ original ]( elems ); + + // Support: Android <=4.0 only, PhantomJS 1 only + // .get() because push.apply(_, arraylike) throws on ancient WebKit + push.apply( ret, elems.get() ); + } + + return this.pushStack( ret ); + }; +} ); +var rmargin = ( /^margin/ ); + +var rnumnonpx = new RegExp( "^(" + pnum + ")(?!px)[a-z%]+$", "i" ); + +var getStyles = function( elem ) { + + // Support: IE <=11 only, Firefox <=30 (#15098, #14150) + // IE throws on elements created in popups + // FF meanwhile throws on frame elements through "defaultView.getComputedStyle" + var view = elem.ownerDocument.defaultView; + + if ( !view || !view.opener ) { + view = window; + } + + return view.getComputedStyle( elem ); + }; + + + +( function() { + + // Executing both pixelPosition & boxSizingReliable tests require only one layout + // so they're executed at the same time to save the second computation. + function computeStyleTests() { + + // This is a singleton, we need to execute it only once + if ( !div ) { + return; + } + + div.style.cssText = + "box-sizing:border-box;" + + "position:relative;display:block;" + + "margin:auto;border:1px;padding:1px;" + + "top:1%;width:50%"; + div.innerHTML = ""; + documentElement.appendChild( container ); + + var divStyle = window.getComputedStyle( div ); + pixelPositionVal = divStyle.top !== "1%"; + + // Support: Android 4.0 - 4.3 only, Firefox <=3 - 44 + reliableMarginLeftVal = divStyle.marginLeft === "2px"; + boxSizingReliableVal = divStyle.width === "4px"; + + // Support: Android 4.0 - 4.3 only + // Some styles come back with percentage values, even though they shouldn't + div.style.marginRight = "50%"; + pixelMarginRightVal = divStyle.marginRight === "4px"; + + documentElement.removeChild( container ); + + // Nullify the div so it wouldn't be stored in the memory and + // it will also be a sign that checks already performed + div = null; + } + + var pixelPositionVal, boxSizingReliableVal, pixelMarginRightVal, reliableMarginLeftVal, + container = document.createElement( "div" ), + div = document.createElement( "div" ); + + // Finish early in limited (non-browser) environments + if ( !div.style ) { + return; + } + + // Support: IE <=9 - 11 only + // Style of cloned element affects source element cloned (#8908) + div.style.backgroundClip = "content-box"; + div.cloneNode( true ).style.backgroundClip = ""; + support.clearCloneStyle = div.style.backgroundClip === "content-box"; + + container.style.cssText = "border:0;width:8px;height:0;top:0;left:-9999px;" + + "padding:0;margin-top:1px;position:absolute"; + container.appendChild( div ); + + jQuery.extend( support, { + pixelPosition: function() { + computeStyleTests(); + return pixelPositionVal; + }, + boxSizingReliable: function() { + computeStyleTests(); + return boxSizingReliableVal; + }, + pixelMarginRight: function() { + computeStyleTests(); + return pixelMarginRightVal; + }, + reliableMarginLeft: function() { + computeStyleTests(); + return reliableMarginLeftVal; + } + } ); +} )(); + + +function curCSS( elem, name, computed ) { + var width, minWidth, maxWidth, ret, + style = elem.style; + + computed = computed || getStyles( elem ); + + // Support: IE <=9 only + // getPropertyValue is only needed for .css('filter') (#12537) + if ( computed ) { + ret = computed.getPropertyValue( name ) || computed[ name ]; + + if ( ret === "" && !jQuery.contains( elem.ownerDocument, elem ) ) { + ret = jQuery.style( elem, name ); + } + + // A tribute to the "awesome hack by Dean Edwards" + // Android Browser returns percentage for some values, + // but width seems to be reliably pixels. + // This is against the CSSOM draft spec: + // https://drafts.csswg.org/cssom/#resolved-values + if ( !support.pixelMarginRight() && rnumnonpx.test( ret ) && rmargin.test( name ) ) { + + // Remember the original values + width = style.width; + minWidth = style.minWidth; + maxWidth = style.maxWidth; + + // Put in the new values to get a computed value out + style.minWidth = style.maxWidth = style.width = ret; + ret = computed.width; + + // Revert the changed values + style.width = width; + style.minWidth = minWidth; + style.maxWidth = maxWidth; + } + } + + return ret !== undefined ? + + // Support: IE <=9 - 11 only + // IE returns zIndex value as an integer. + ret + "" : + ret; +} + + +function addGetHookIf( conditionFn, hookFn ) { + + // Define the hook, we'll check on the first run if it's really needed. + return { + get: function() { + if ( conditionFn() ) { + + // Hook not needed (or it's not possible to use it due + // to missing dependency), remove it. + delete this.get; + return; + } + + // Hook needed; redefine it so that the support test is not executed again. + return ( this.get = hookFn ).apply( this, arguments ); + } + }; +} + + +var + + // Swappable if display is none or starts with table + // except "table", "table-cell", or "table-caption" + // See here for display values: https://developer.mozilla.org/en-US/docs/CSS/display + rdisplayswap = /^(none|table(?!-c[ea]).+)/, + cssShow = { position: "absolute", visibility: "hidden", display: "block" }, + cssNormalTransform = { + letterSpacing: "0", + fontWeight: "400" + }, + + cssPrefixes = [ "Webkit", "Moz", "ms" ], + emptyStyle = document.createElement( "div" ).style; + +// Return a css property mapped to a potentially vendor prefixed property +function vendorPropName( name ) { + + // Shortcut for names that are not vendor prefixed + if ( name in emptyStyle ) { + return name; + } + + // Check for vendor prefixed names + var capName = name[ 0 ].toUpperCase() + name.slice( 1 ), + i = cssPrefixes.length; + + while ( i-- ) { + name = cssPrefixes[ i ] + capName; + if ( name in emptyStyle ) { + return name; + } + } +} + +function setPositiveNumber( elem, value, subtract ) { + + // Any relative (+/-) values have already been + // normalized at this point + var matches = rcssNum.exec( value ); + return matches ? + + // Guard against undefined "subtract", e.g., when used as in cssHooks + Math.max( 0, matches[ 2 ] - ( subtract || 0 ) ) + ( matches[ 3 ] || "px" ) : + value; +} + +function augmentWidthOrHeight( elem, name, extra, isBorderBox, styles ) { + var i = extra === ( isBorderBox ? "border" : "content" ) ? + + // If we already have the right measurement, avoid augmentation + 4 : + + // Otherwise initialize for horizontal or vertical properties + name === "width" ? 1 : 0, + + val = 0; + + for ( ; i < 4; i += 2 ) { + + // Both box models exclude margin, so add it if we want it + if ( extra === "margin" ) { + val += jQuery.css( elem, extra + cssExpand[ i ], true, styles ); + } + + if ( isBorderBox ) { + + // border-box includes padding, so remove it if we want content + if ( extra === "content" ) { + val -= jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + } + + // At this point, extra isn't border nor margin, so remove border + if ( extra !== "margin" ) { + val -= jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + } else { + + // At this point, extra isn't content, so add padding + val += jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + + // At this point, extra isn't content nor padding, so add border + if ( extra !== "padding" ) { + val += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + } + } + + return val; +} + +function getWidthOrHeight( elem, name, extra ) { + + // Start with offset property, which is equivalent to the border-box value + var val, + valueIsBorderBox = true, + styles = getStyles( elem ), + isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box"; + + // Support: IE <=11 only + // Running getBoundingClientRect on a disconnected node + // in IE throws an error. + if ( elem.getClientRects().length ) { + val = elem.getBoundingClientRect()[ name ]; + } + + // Some non-html elements return undefined for offsetWidth, so check for null/undefined + // svg - https://bugzilla.mozilla.org/show_bug.cgi?id=649285 + // MathML - https://bugzilla.mozilla.org/show_bug.cgi?id=491668 + if ( val <= 0 || val == null ) { + + // Fall back to computed then uncomputed css if necessary + val = curCSS( elem, name, styles ); + if ( val < 0 || val == null ) { + val = elem.style[ name ]; + } + + // Computed unit is not pixels. Stop here and return. + if ( rnumnonpx.test( val ) ) { + return val; + } + + // Check for style in case a browser which returns unreliable values + // for getComputedStyle silently falls back to the reliable elem.style + valueIsBorderBox = isBorderBox && + ( support.boxSizingReliable() || val === elem.style[ name ] ); + + // Normalize "", auto, and prepare for extra + val = parseFloat( val ) || 0; + } + + // Use the active box-sizing model to add/subtract irrelevant styles + return ( val + + augmentWidthOrHeight( + elem, + name, + extra || ( isBorderBox ? "border" : "content" ), + valueIsBorderBox, + styles + ) + ) + "px"; +} + +jQuery.extend( { + + // Add in style property hooks for overriding the default + // behavior of getting and setting a style property + cssHooks: { + opacity: { + get: function( elem, computed ) { + if ( computed ) { + + // We should always get a number back from opacity + var ret = curCSS( elem, "opacity" ); + return ret === "" ? "1" : ret; + } + } + } + }, + + // Don't automatically add "px" to these possibly-unitless properties + cssNumber: { + "animationIterationCount": true, + "columnCount": true, + "fillOpacity": true, + "flexGrow": true, + "flexShrink": true, + "fontWeight": true, + "lineHeight": true, + "opacity": true, + "order": true, + "orphans": true, + "widows": true, + "zIndex": true, + "zoom": true + }, + + // Add in properties whose names you wish to fix before + // setting or getting the value + cssProps: { + "float": "cssFloat" + }, + + // Get and set the style property on a DOM Node + style: function( elem, name, value, extra ) { + + // Don't set styles on text and comment nodes + if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) { + return; + } + + // Make sure that we're working with the right name + var ret, type, hooks, + origName = jQuery.camelCase( name ), + style = elem.style; + + name = jQuery.cssProps[ origName ] || + ( jQuery.cssProps[ origName ] = vendorPropName( origName ) || origName ); + + // Gets hook for the prefixed version, then unprefixed version + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // Check if we're setting a value + if ( value !== undefined ) { + type = typeof value; + + // Convert "+=" or "-=" to relative numbers (#7345) + if ( type === "string" && ( ret = rcssNum.exec( value ) ) && ret[ 1 ] ) { + value = adjustCSS( elem, name, ret ); + + // Fixes bug #9237 + type = "number"; + } + + // Make sure that null and NaN values aren't set (#7116) + if ( value == null || value !== value ) { + return; + } + + // If a number was passed in, add the unit (except for certain CSS properties) + if ( type === "number" ) { + value += ret && ret[ 3 ] || ( jQuery.cssNumber[ origName ] ? "" : "px" ); + } + + // background-* props affect original clone's values + if ( !support.clearCloneStyle && value === "" && name.indexOf( "background" ) === 0 ) { + style[ name ] = "inherit"; + } + + // If a hook was provided, use that value, otherwise just set the specified value + if ( !hooks || !( "set" in hooks ) || + ( value = hooks.set( elem, value, extra ) ) !== undefined ) { + + style[ name ] = value; + } + + } else { + + // If a hook was provided get the non-computed value from there + if ( hooks && "get" in hooks && + ( ret = hooks.get( elem, false, extra ) ) !== undefined ) { + + return ret; + } + + // Otherwise just get the value from the style object + return style[ name ]; + } + }, + + css: function( elem, name, extra, styles ) { + var val, num, hooks, + origName = jQuery.camelCase( name ); + + // Make sure that we're working with the right name + name = jQuery.cssProps[ origName ] || + ( jQuery.cssProps[ origName ] = vendorPropName( origName ) || origName ); + + // Try prefixed name followed by the unprefixed name + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // If a hook was provided get the computed value from there + if ( hooks && "get" in hooks ) { + val = hooks.get( elem, true, extra ); + } + + // Otherwise, if a way to get the computed value exists, use that + if ( val === undefined ) { + val = curCSS( elem, name, styles ); + } + + // Convert "normal" to computed value + if ( val === "normal" && name in cssNormalTransform ) { + val = cssNormalTransform[ name ]; + } + + // Make numeric if forced or a qualifier was provided and val looks numeric + if ( extra === "" || extra ) { + num = parseFloat( val ); + return extra === true || isFinite( num ) ? num || 0 : val; + } + return val; + } +} ); + +jQuery.each( [ "height", "width" ], function( i, name ) { + jQuery.cssHooks[ name ] = { + get: function( elem, computed, extra ) { + if ( computed ) { + + // Certain elements can have dimension info if we invisibly show them + // but it must have a current display style that would benefit + return rdisplayswap.test( jQuery.css( elem, "display" ) ) && + + // Support: Safari 8+ + // Table columns in Safari have non-zero offsetWidth & zero + // getBoundingClientRect().width unless display is changed. + // Support: IE <=11 only + // Running getBoundingClientRect on a disconnected node + // in IE throws an error. + ( !elem.getClientRects().length || !elem.getBoundingClientRect().width ) ? + swap( elem, cssShow, function() { + return getWidthOrHeight( elem, name, extra ); + } ) : + getWidthOrHeight( elem, name, extra ); + } + }, + + set: function( elem, value, extra ) { + var matches, + styles = extra && getStyles( elem ), + subtract = extra && augmentWidthOrHeight( + elem, + name, + extra, + jQuery.css( elem, "boxSizing", false, styles ) === "border-box", + styles + ); + + // Convert to pixels if value adjustment is needed + if ( subtract && ( matches = rcssNum.exec( value ) ) && + ( matches[ 3 ] || "px" ) !== "px" ) { + + elem.style[ name ] = value; + value = jQuery.css( elem, name ); + } + + return setPositiveNumber( elem, value, subtract ); + } + }; +} ); + +jQuery.cssHooks.marginLeft = addGetHookIf( support.reliableMarginLeft, + function( elem, computed ) { + if ( computed ) { + return ( parseFloat( curCSS( elem, "marginLeft" ) ) || + elem.getBoundingClientRect().left - + swap( elem, { marginLeft: 0 }, function() { + return elem.getBoundingClientRect().left; + } ) + ) + "px"; + } + } +); + +// These hooks are used by animate to expand properties +jQuery.each( { + margin: "", + padding: "", + border: "Width" +}, function( prefix, suffix ) { + jQuery.cssHooks[ prefix + suffix ] = { + expand: function( value ) { + var i = 0, + expanded = {}, + + // Assumes a single number if not a string + parts = typeof value === "string" ? value.split( " " ) : [ value ]; + + for ( ; i < 4; i++ ) { + expanded[ prefix + cssExpand[ i ] + suffix ] = + parts[ i ] || parts[ i - 2 ] || parts[ 0 ]; + } + + return expanded; + } + }; + + if ( !rmargin.test( prefix ) ) { + jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber; + } +} ); + +jQuery.fn.extend( { + css: function( name, value ) { + return access( this, function( elem, name, value ) { + var styles, len, + map = {}, + i = 0; + + if ( jQuery.isArray( name ) ) { + styles = getStyles( elem ); + len = name.length; + + for ( ; i < len; i++ ) { + map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles ); + } + + return map; + } + + return value !== undefined ? + jQuery.style( elem, name, value ) : + jQuery.css( elem, name ); + }, name, value, arguments.length > 1 ); + } +} ); + + +function Tween( elem, options, prop, end, easing ) { + return new Tween.prototype.init( elem, options, prop, end, easing ); +} +jQuery.Tween = Tween; + +Tween.prototype = { + constructor: Tween, + init: function( elem, options, prop, end, easing, unit ) { + this.elem = elem; + this.prop = prop; + this.easing = easing || jQuery.easing._default; + this.options = options; + this.start = this.now = this.cur(); + this.end = end; + this.unit = unit || ( jQuery.cssNumber[ prop ] ? "" : "px" ); + }, + cur: function() { + var hooks = Tween.propHooks[ this.prop ]; + + return hooks && hooks.get ? + hooks.get( this ) : + Tween.propHooks._default.get( this ); + }, + run: function( percent ) { + var eased, + hooks = Tween.propHooks[ this.prop ]; + + if ( this.options.duration ) { + this.pos = eased = jQuery.easing[ this.easing ]( + percent, this.options.duration * percent, 0, 1, this.options.duration + ); + } else { + this.pos = eased = percent; + } + this.now = ( this.end - this.start ) * eased + this.start; + + if ( this.options.step ) { + this.options.step.call( this.elem, this.now, this ); + } + + if ( hooks && hooks.set ) { + hooks.set( this ); + } else { + Tween.propHooks._default.set( this ); + } + return this; + } +}; + +Tween.prototype.init.prototype = Tween.prototype; + +Tween.propHooks = { + _default: { + get: function( tween ) { + var result; + + // Use a property on the element directly when it is not a DOM element, + // or when there is no matching style property that exists. + if ( tween.elem.nodeType !== 1 || + tween.elem[ tween.prop ] != null && tween.elem.style[ tween.prop ] == null ) { + return tween.elem[ tween.prop ]; + } + + // Passing an empty string as a 3rd parameter to .css will automatically + // attempt a parseFloat and fallback to a string if the parse fails. + // Simple values such as "10px" are parsed to Float; + // complex values such as "rotate(1rad)" are returned as-is. + result = jQuery.css( tween.elem, tween.prop, "" ); + + // Empty strings, null, undefined and "auto" are converted to 0. + return !result || result === "auto" ? 0 : result; + }, + set: function( tween ) { + + // Use step hook for back compat. + // Use cssHook if its there. + // Use .style if available and use plain properties where available. + if ( jQuery.fx.step[ tween.prop ] ) { + jQuery.fx.step[ tween.prop ]( tween ); + } else if ( tween.elem.nodeType === 1 && + ( tween.elem.style[ jQuery.cssProps[ tween.prop ] ] != null || + jQuery.cssHooks[ tween.prop ] ) ) { + jQuery.style( tween.elem, tween.prop, tween.now + tween.unit ); + } else { + tween.elem[ tween.prop ] = tween.now; + } + } + } +}; + +// Support: IE <=9 only +// Panic based approach to setting things on disconnected nodes +Tween.propHooks.scrollTop = Tween.propHooks.scrollLeft = { + set: function( tween ) { + if ( tween.elem.nodeType && tween.elem.parentNode ) { + tween.elem[ tween.prop ] = tween.now; + } + } +}; + +jQuery.easing = { + linear: function( p ) { + return p; + }, + swing: function( p ) { + return 0.5 - Math.cos( p * Math.PI ) / 2; + }, + _default: "swing" +}; + +jQuery.fx = Tween.prototype.init; + +// Back compat <1.8 extension point +jQuery.fx.step = {}; + + + + +var + fxNow, timerId, + rfxtypes = /^(?:toggle|show|hide)$/, + rrun = /queueHooks$/; + +function raf() { + if ( timerId ) { + window.requestAnimationFrame( raf ); + jQuery.fx.tick(); + } +} + +// Animations created synchronously will run synchronously +function createFxNow() { + window.setTimeout( function() { + fxNow = undefined; + } ); + return ( fxNow = jQuery.now() ); +} + +// Generate parameters to create a standard animation +function genFx( type, includeWidth ) { + var which, + i = 0, + attrs = { height: type }; + + // If we include width, step value is 1 to do all cssExpand values, + // otherwise step value is 2 to skip over Left and Right + includeWidth = includeWidth ? 1 : 0; + for ( ; i < 4 ; i += 2 - includeWidth ) { + which = cssExpand[ i ]; + attrs[ "margin" + which ] = attrs[ "padding" + which ] = type; + } + + if ( includeWidth ) { + attrs.opacity = attrs.width = type; + } + + return attrs; +} + +function createTween( value, prop, animation ) { + var tween, + collection = ( Animation.tweeners[ prop ] || [] ).concat( Animation.tweeners[ "*" ] ), + index = 0, + length = collection.length; + for ( ; index < length; index++ ) { + if ( ( tween = collection[ index ].call( animation, prop, value ) ) ) { + + // We're done with this property + return tween; + } + } +} + +function defaultPrefilter( elem, props, opts ) { + /* jshint validthis: true */ + var prop, value, toggle, hooks, oldfire, propTween, restoreDisplay, display, + isBox = "width" in props || "height" in props, + anim = this, + orig = {}, + style = elem.style, + hidden = elem.nodeType && isHiddenWithinTree( elem ), + dataShow = dataPriv.get( elem, "fxshow" ); + + // Queue-skipping animations hijack the fx hooks + if ( !opts.queue ) { + hooks = jQuery._queueHooks( elem, "fx" ); + if ( hooks.unqueued == null ) { + hooks.unqueued = 0; + oldfire = hooks.empty.fire; + hooks.empty.fire = function() { + if ( !hooks.unqueued ) { + oldfire(); + } + }; + } + hooks.unqueued++; + + anim.always( function() { + + // Ensure the complete handler is called before this completes + anim.always( function() { + hooks.unqueued--; + if ( !jQuery.queue( elem, "fx" ).length ) { + hooks.empty.fire(); + } + } ); + } ); + } + + // Detect show/hide animations + for ( prop in props ) { + value = props[ prop ]; + if ( rfxtypes.test( value ) ) { + delete props[ prop ]; + toggle = toggle || value === "toggle"; + if ( value === ( hidden ? "hide" : "show" ) ) { + + // Pretend to be hidden if this is a "show" and + // there is still data from a stopped show/hide + if ( value === "show" && dataShow && dataShow[ prop ] !== undefined ) { + hidden = true; + + // Ignore all other no-op show/hide data + } else { + continue; + } + } + orig[ prop ] = dataShow && dataShow[ prop ] || jQuery.style( elem, prop ); + } + } + + // Bail out if this is a no-op like .hide().hide() + propTween = !jQuery.isEmptyObject( props ); + if ( !propTween && jQuery.isEmptyObject( orig ) ) { + return; + } + + // Restrict "overflow" and "display" styles during box animations + if ( isBox && elem.nodeType === 1 ) { + + // Support: IE <=9 - 11, Edge 12 - 13 + // Record all 3 overflow attributes because IE does not infer the shorthand + // from identically-valued overflowX and overflowY + opts.overflow = [ style.overflow, style.overflowX, style.overflowY ]; + + // Identify a display type, preferring old show/hide data over the CSS cascade + restoreDisplay = dataShow && dataShow.display; + if ( restoreDisplay == null ) { + restoreDisplay = dataPriv.get( elem, "display" ); + } + display = jQuery.css( elem, "display" ); + if ( display === "none" ) { + if ( restoreDisplay ) { + display = restoreDisplay; + } else { + + // Get nonempty value(s) by temporarily forcing visibility + showHide( [ elem ], true ); + restoreDisplay = elem.style.display || restoreDisplay; + display = jQuery.css( elem, "display" ); + showHide( [ elem ] ); + } + } + + // Animate inline elements as inline-block + if ( display === "inline" || display === "inline-block" && restoreDisplay != null ) { + if ( jQuery.css( elem, "float" ) === "none" ) { + + // Restore the original display value at the end of pure show/hide animations + if ( !propTween ) { + anim.done( function() { + style.display = restoreDisplay; + } ); + if ( restoreDisplay == null ) { + display = style.display; + restoreDisplay = display === "none" ? "" : display; + } + } + style.display = "inline-block"; + } + } + } + + if ( opts.overflow ) { + style.overflow = "hidden"; + anim.always( function() { + style.overflow = opts.overflow[ 0 ]; + style.overflowX = opts.overflow[ 1 ]; + style.overflowY = opts.overflow[ 2 ]; + } ); + } + + // Implement show/hide animations + propTween = false; + for ( prop in orig ) { + + // General show/hide setup for this element animation + if ( !propTween ) { + if ( dataShow ) { + if ( "hidden" in dataShow ) { + hidden = dataShow.hidden; + } + } else { + dataShow = dataPriv.access( elem, "fxshow", { display: restoreDisplay } ); + } + + // Store hidden/visible for toggle so `.stop().toggle()` "reverses" + if ( toggle ) { + dataShow.hidden = !hidden; + } + + // Show elements before animating them + if ( hidden ) { + showHide( [ elem ], true ); + } + + /* jshint -W083 */ + anim.done( function() { + + // The final step of a "hide" animation is actually hiding the element + if ( !hidden ) { + showHide( [ elem ] ); + } + dataPriv.remove( elem, "fxshow" ); + for ( prop in orig ) { + jQuery.style( elem, prop, orig[ prop ] ); + } + } ); + } + + // Per-property setup + propTween = createTween( hidden ? dataShow[ prop ] : 0, prop, anim ); + if ( !( prop in dataShow ) ) { + dataShow[ prop ] = propTween.start; + if ( hidden ) { + propTween.end = propTween.start; + propTween.start = 0; + } + } + } +} + +function propFilter( props, specialEasing ) { + var index, name, easing, value, hooks; + + // camelCase, specialEasing and expand cssHook pass + for ( index in props ) { + name = jQuery.camelCase( index ); + easing = specialEasing[ name ]; + value = props[ index ]; + if ( jQuery.isArray( value ) ) { + easing = value[ 1 ]; + value = props[ index ] = value[ 0 ]; + } + + if ( index !== name ) { + props[ name ] = value; + delete props[ index ]; + } + + hooks = jQuery.cssHooks[ name ]; + if ( hooks && "expand" in hooks ) { + value = hooks.expand( value ); + delete props[ name ]; + + // Not quite $.extend, this won't overwrite existing keys. + // Reusing 'index' because we have the correct "name" + for ( index in value ) { + if ( !( index in props ) ) { + props[ index ] = value[ index ]; + specialEasing[ index ] = easing; + } + } + } else { + specialEasing[ name ] = easing; + } + } +} + +function Animation( elem, properties, options ) { + var result, + stopped, + index = 0, + length = Animation.prefilters.length, + deferred = jQuery.Deferred().always( function() { + + // Don't match elem in the :animated selector + delete tick.elem; + } ), + tick = function() { + if ( stopped ) { + return false; + } + var currentTime = fxNow || createFxNow(), + remaining = Math.max( 0, animation.startTime + animation.duration - currentTime ), + + // Support: Android 2.3 only + // Archaic crash bug won't allow us to use `1 - ( 0.5 || 0 )` (#12497) + temp = remaining / animation.duration || 0, + percent = 1 - temp, + index = 0, + length = animation.tweens.length; + + for ( ; index < length ; index++ ) { + animation.tweens[ index ].run( percent ); + } + + deferred.notifyWith( elem, [ animation, percent, remaining ] ); + + if ( percent < 1 && length ) { + return remaining; + } else { + deferred.resolveWith( elem, [ animation ] ); + return false; + } + }, + animation = deferred.promise( { + elem: elem, + props: jQuery.extend( {}, properties ), + opts: jQuery.extend( true, { + specialEasing: {}, + easing: jQuery.easing._default + }, options ), + originalProperties: properties, + originalOptions: options, + startTime: fxNow || createFxNow(), + duration: options.duration, + tweens: [], + createTween: function( prop, end ) { + var tween = jQuery.Tween( elem, animation.opts, prop, end, + animation.opts.specialEasing[ prop ] || animation.opts.easing ); + animation.tweens.push( tween ); + return tween; + }, + stop: function( gotoEnd ) { + var index = 0, + + // If we are going to the end, we want to run all the tweens + // otherwise we skip this part + length = gotoEnd ? animation.tweens.length : 0; + if ( stopped ) { + return this; + } + stopped = true; + for ( ; index < length ; index++ ) { + animation.tweens[ index ].run( 1 ); + } + + // Resolve when we played the last frame; otherwise, reject + if ( gotoEnd ) { + deferred.notifyWith( elem, [ animation, 1, 0 ] ); + deferred.resolveWith( elem, [ animation, gotoEnd ] ); + } else { + deferred.rejectWith( elem, [ animation, gotoEnd ] ); + } + return this; + } + } ), + props = animation.props; + + propFilter( props, animation.opts.specialEasing ); + + for ( ; index < length ; index++ ) { + result = Animation.prefilters[ index ].call( animation, elem, props, animation.opts ); + if ( result ) { + if ( jQuery.isFunction( result.stop ) ) { + jQuery._queueHooks( animation.elem, animation.opts.queue ).stop = + jQuery.proxy( result.stop, result ); + } + return result; + } + } + + jQuery.map( props, createTween, animation ); + + if ( jQuery.isFunction( animation.opts.start ) ) { + animation.opts.start.call( elem, animation ); + } + + jQuery.fx.timer( + jQuery.extend( tick, { + elem: elem, + anim: animation, + queue: animation.opts.queue + } ) + ); + + // attach callbacks from options + return animation.progress( animation.opts.progress ) + .done( animation.opts.done, animation.opts.complete ) + .fail( animation.opts.fail ) + .always( animation.opts.always ); +} + +jQuery.Animation = jQuery.extend( Animation, { + + tweeners: { + "*": [ function( prop, value ) { + var tween = this.createTween( prop, value ); + adjustCSS( tween.elem, prop, rcssNum.exec( value ), tween ); + return tween; + } ] + }, + + tweener: function( props, callback ) { + if ( jQuery.isFunction( props ) ) { + callback = props; + props = [ "*" ]; + } else { + props = props.match( rnotwhite ); + } + + var prop, + index = 0, + length = props.length; + + for ( ; index < length ; index++ ) { + prop = props[ index ]; + Animation.tweeners[ prop ] = Animation.tweeners[ prop ] || []; + Animation.tweeners[ prop ].unshift( callback ); + } + }, + + prefilters: [ defaultPrefilter ], + + prefilter: function( callback, prepend ) { + if ( prepend ) { + Animation.prefilters.unshift( callback ); + } else { + Animation.prefilters.push( callback ); + } + } +} ); + +jQuery.speed = function( speed, easing, fn ) { + var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : { + complete: fn || !fn && easing || + jQuery.isFunction( speed ) && speed, + duration: speed, + easing: fn && easing || easing && !jQuery.isFunction( easing ) && easing + }; + + // Go to the end state if fx are off or if document is hidden + if ( jQuery.fx.off || document.hidden ) { + opt.duration = 0; + + } else { + opt.duration = typeof opt.duration === "number" ? + opt.duration : opt.duration in jQuery.fx.speeds ? + jQuery.fx.speeds[ opt.duration ] : jQuery.fx.speeds._default; + } + + // Normalize opt.queue - true/undefined/null -> "fx" + if ( opt.queue == null || opt.queue === true ) { + opt.queue = "fx"; + } + + // Queueing + opt.old = opt.complete; + + opt.complete = function() { + if ( jQuery.isFunction( opt.old ) ) { + opt.old.call( this ); + } + + if ( opt.queue ) { + jQuery.dequeue( this, opt.queue ); + } + }; + + return opt; +}; + +jQuery.fn.extend( { + fadeTo: function( speed, to, easing, callback ) { + + // Show any hidden elements after setting opacity to 0 + return this.filter( isHiddenWithinTree ).css( "opacity", 0 ).show() + + // Animate to the value specified + .end().animate( { opacity: to }, speed, easing, callback ); + }, + animate: function( prop, speed, easing, callback ) { + var empty = jQuery.isEmptyObject( prop ), + optall = jQuery.speed( speed, easing, callback ), + doAnimation = function() { + + // Operate on a copy of prop so per-property easing won't be lost + var anim = Animation( this, jQuery.extend( {}, prop ), optall ); + + // Empty animations, or finishing resolves immediately + if ( empty || dataPriv.get( this, "finish" ) ) { + anim.stop( true ); + } + }; + doAnimation.finish = doAnimation; + + return empty || optall.queue === false ? + this.each( doAnimation ) : + this.queue( optall.queue, doAnimation ); + }, + stop: function( type, clearQueue, gotoEnd ) { + var stopQueue = function( hooks ) { + var stop = hooks.stop; + delete hooks.stop; + stop( gotoEnd ); + }; + + if ( typeof type !== "string" ) { + gotoEnd = clearQueue; + clearQueue = type; + type = undefined; + } + if ( clearQueue && type !== false ) { + this.queue( type || "fx", [] ); + } + + return this.each( function() { + var dequeue = true, + index = type != null && type + "queueHooks", + timers = jQuery.timers, + data = dataPriv.get( this ); + + if ( index ) { + if ( data[ index ] && data[ index ].stop ) { + stopQueue( data[ index ] ); + } + } else { + for ( index in data ) { + if ( data[ index ] && data[ index ].stop && rrun.test( index ) ) { + stopQueue( data[ index ] ); + } + } + } + + for ( index = timers.length; index--; ) { + if ( timers[ index ].elem === this && + ( type == null || timers[ index ].queue === type ) ) { + + timers[ index ].anim.stop( gotoEnd ); + dequeue = false; + timers.splice( index, 1 ); + } + } + + // Start the next in the queue if the last step wasn't forced. + // Timers currently will call their complete callbacks, which + // will dequeue but only if they were gotoEnd. + if ( dequeue || !gotoEnd ) { + jQuery.dequeue( this, type ); + } + } ); + }, + finish: function( type ) { + if ( type !== false ) { + type = type || "fx"; + } + return this.each( function() { + var index, + data = dataPriv.get( this ), + queue = data[ type + "queue" ], + hooks = data[ type + "queueHooks" ], + timers = jQuery.timers, + length = queue ? queue.length : 0; + + // Enable finishing flag on private data + data.finish = true; + + // Empty the queue first + jQuery.queue( this, type, [] ); + + if ( hooks && hooks.stop ) { + hooks.stop.call( this, true ); + } + + // Look for any active animations, and finish them + for ( index = timers.length; index--; ) { + if ( timers[ index ].elem === this && timers[ index ].queue === type ) { + timers[ index ].anim.stop( true ); + timers.splice( index, 1 ); + } + } + + // Look for any animations in the old queue and finish them + for ( index = 0; index < length; index++ ) { + if ( queue[ index ] && queue[ index ].finish ) { + queue[ index ].finish.call( this ); + } + } + + // Turn off finishing flag + delete data.finish; + } ); + } +} ); + +jQuery.each( [ "toggle", "show", "hide" ], function( i, name ) { + var cssFn = jQuery.fn[ name ]; + jQuery.fn[ name ] = function( speed, easing, callback ) { + return speed == null || typeof speed === "boolean" ? + cssFn.apply( this, arguments ) : + this.animate( genFx( name, true ), speed, easing, callback ); + }; +} ); + +// Generate shortcuts for custom animations +jQuery.each( { + slideDown: genFx( "show" ), + slideUp: genFx( "hide" ), + slideToggle: genFx( "toggle" ), + fadeIn: { opacity: "show" }, + fadeOut: { opacity: "hide" }, + fadeToggle: { opacity: "toggle" } +}, function( name, props ) { + jQuery.fn[ name ] = function( speed, easing, callback ) { + return this.animate( props, speed, easing, callback ); + }; +} ); + +jQuery.timers = []; +jQuery.fx.tick = function() { + var timer, + i = 0, + timers = jQuery.timers; + + fxNow = jQuery.now(); + + for ( ; i < timers.length; i++ ) { + timer = timers[ i ]; + + // Checks the timer has not already been removed + if ( !timer() && timers[ i ] === timer ) { + timers.splice( i--, 1 ); + } + } + + if ( !timers.length ) { + jQuery.fx.stop(); + } + fxNow = undefined; +}; + +jQuery.fx.timer = function( timer ) { + jQuery.timers.push( timer ); + if ( timer() ) { + jQuery.fx.start(); + } else { + jQuery.timers.pop(); + } +}; + +jQuery.fx.interval = 13; +jQuery.fx.start = function() { + if ( !timerId ) { + timerId = window.requestAnimationFrame ? + window.requestAnimationFrame( raf ) : + window.setInterval( jQuery.fx.tick, jQuery.fx.interval ); + } +}; + +jQuery.fx.stop = function() { + if ( window.cancelAnimationFrame ) { + window.cancelAnimationFrame( timerId ); + } else { + window.clearInterval( timerId ); + } + + timerId = null; +}; + +jQuery.fx.speeds = { + slow: 600, + fast: 200, + + // Default speed + _default: 400 +}; + + +// Based off of the plugin by Clint Helfers, with permission. +// https://web.archive.org/web/20100324014747/http://blindsignals.com/index.php/2009/07/jquery-delay/ +jQuery.fn.delay = function( time, type ) { + time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; + type = type || "fx"; + + return this.queue( type, function( next, hooks ) { + var timeout = window.setTimeout( next, time ); + hooks.stop = function() { + window.clearTimeout( timeout ); + }; + } ); +}; + + +( function() { + var input = document.createElement( "input" ), + select = document.createElement( "select" ), + opt = select.appendChild( document.createElement( "option" ) ); + + input.type = "checkbox"; + + // Support: Android <=4.3 only + // Default value for a checkbox should be "on" + support.checkOn = input.value !== ""; + + // Support: IE <=11 only + // Must access selectedIndex to make default options select + support.optSelected = opt.selected; + + // Support: IE <=11 only + // An input loses its value after becoming a radio + input = document.createElement( "input" ); + input.value = "t"; + input.type = "radio"; + support.radioValue = input.value === "t"; +} )(); + + +var boolHook, + attrHandle = jQuery.expr.attrHandle; + +jQuery.fn.extend( { + attr: function( name, value ) { + return access( this, jQuery.attr, name, value, arguments.length > 1 ); + }, + + removeAttr: function( name ) { + return this.each( function() { + jQuery.removeAttr( this, name ); + } ); + } +} ); + +jQuery.extend( { + attr: function( elem, name, value ) { + var ret, hooks, + nType = elem.nodeType; + + // Don't get/set attributes on text, comment and attribute nodes + if ( nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + // Fallback to prop when attributes are not supported + if ( typeof elem.getAttribute === "undefined" ) { + return jQuery.prop( elem, name, value ); + } + + // Attribute hooks are determined by the lowercase version + // Grab necessary hook if one is defined + if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { + hooks = jQuery.attrHooks[ name.toLowerCase() ] || + ( jQuery.expr.match.bool.test( name ) ? boolHook : undefined ); + } + + if ( value !== undefined ) { + if ( value === null ) { + jQuery.removeAttr( elem, name ); + return; + } + + if ( hooks && "set" in hooks && + ( ret = hooks.set( elem, value, name ) ) !== undefined ) { + return ret; + } + + elem.setAttribute( name, value + "" ); + return value; + } + + if ( hooks && "get" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) { + return ret; + } + + ret = jQuery.find.attr( elem, name ); + + // Non-existent attributes return null, we normalize to undefined + return ret == null ? undefined : ret; + }, + + attrHooks: { + type: { + set: function( elem, value ) { + if ( !support.radioValue && value === "radio" && + jQuery.nodeName( elem, "input" ) ) { + var val = elem.value; + elem.setAttribute( "type", value ); + if ( val ) { + elem.value = val; + } + return value; + } + } + } + }, + + removeAttr: function( elem, value ) { + var name, + i = 0, + attrNames = value && value.match( rnotwhite ); + + if ( attrNames && elem.nodeType === 1 ) { + while ( ( name = attrNames[ i++ ] ) ) { + elem.removeAttribute( name ); + } + } + } +} ); + +// Hooks for boolean attributes +boolHook = { + set: function( elem, value, name ) { + if ( value === false ) { + + // Remove boolean attributes when set to false + jQuery.removeAttr( elem, name ); + } else { + elem.setAttribute( name, name ); + } + return name; + } +}; + +jQuery.each( jQuery.expr.match.bool.source.match( /\w+/g ), function( i, name ) { + var getter = attrHandle[ name ] || jQuery.find.attr; + + attrHandle[ name ] = function( elem, name, isXML ) { + var ret, handle, + lowercaseName = name.toLowerCase(); + + if ( !isXML ) { + + // Avoid an infinite loop by temporarily removing this function from the getter + handle = attrHandle[ lowercaseName ]; + attrHandle[ lowercaseName ] = ret; + ret = getter( elem, name, isXML ) != null ? + lowercaseName : + null; + attrHandle[ lowercaseName ] = handle; + } + return ret; + }; +} ); + + + + +var rfocusable = /^(?:input|select|textarea|button)$/i, + rclickable = /^(?:a|area)$/i; + +jQuery.fn.extend( { + prop: function( name, value ) { + return access( this, jQuery.prop, name, value, arguments.length > 1 ); + }, + + removeProp: function( name ) { + return this.each( function() { + delete this[ jQuery.propFix[ name ] || name ]; + } ); + } +} ); + +jQuery.extend( { + prop: function( elem, name, value ) { + var ret, hooks, + nType = elem.nodeType; + + // Don't get/set properties on text, comment and attribute nodes + if ( nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { + + // Fix name and attach hooks + name = jQuery.propFix[ name ] || name; + hooks = jQuery.propHooks[ name ]; + } + + if ( value !== undefined ) { + if ( hooks && "set" in hooks && + ( ret = hooks.set( elem, value, name ) ) !== undefined ) { + return ret; + } + + return ( elem[ name ] = value ); + } + + if ( hooks && "get" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) { + return ret; + } + + return elem[ name ]; + }, + + propHooks: { + tabIndex: { + get: function( elem ) { + + // Support: IE <=9 - 11 only + // elem.tabIndex doesn't always return the + // correct value when it hasn't been explicitly set + // https://web.archive.org/web/20141116233347/http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ + // Use proper attribute retrieval(#12072) + var tabindex = jQuery.find.attr( elem, "tabindex" ); + + return tabindex ? + parseInt( tabindex, 10 ) : + rfocusable.test( elem.nodeName ) || + rclickable.test( elem.nodeName ) && elem.href ? + 0 : + -1; + } + } + }, + + propFix: { + "for": "htmlFor", + "class": "className" + } +} ); + +// Support: IE <=11 only +// Accessing the selectedIndex property +// forces the browser to respect setting selected +// on the option +// The getter ensures a default option is selected +// when in an optgroup +if ( !support.optSelected ) { + jQuery.propHooks.selected = { + get: function( elem ) { + var parent = elem.parentNode; + if ( parent && parent.parentNode ) { + parent.parentNode.selectedIndex; + } + return null; + }, + set: function( elem ) { + var parent = elem.parentNode; + if ( parent ) { + parent.selectedIndex; + + if ( parent.parentNode ) { + parent.parentNode.selectedIndex; + } + } + } + }; +} + +jQuery.each( [ + "tabIndex", + "readOnly", + "maxLength", + "cellSpacing", + "cellPadding", + "rowSpan", + "colSpan", + "useMap", + "frameBorder", + "contentEditable" +], function() { + jQuery.propFix[ this.toLowerCase() ] = this; +} ); + + + + +var rclass = /[\t\r\n\f]/g; + +function getClass( elem ) { + return elem.getAttribute && elem.getAttribute( "class" ) || ""; +} + +jQuery.fn.extend( { + addClass: function( value ) { + var classes, elem, cur, curValue, clazz, j, finalValue, + i = 0; + + if ( jQuery.isFunction( value ) ) { + return this.each( function( j ) { + jQuery( this ).addClass( value.call( this, j, getClass( this ) ) ); + } ); + } + + if ( typeof value === "string" && value ) { + classes = value.match( rnotwhite ) || []; + + while ( ( elem = this[ i++ ] ) ) { + curValue = getClass( elem ); + cur = elem.nodeType === 1 && + ( " " + curValue + " " ).replace( rclass, " " ); + + if ( cur ) { + j = 0; + while ( ( clazz = classes[ j++ ] ) ) { + if ( cur.indexOf( " " + clazz + " " ) < 0 ) { + cur += clazz + " "; + } + } + + // Only assign if different to avoid unneeded rendering. + finalValue = jQuery.trim( cur ); + if ( curValue !== finalValue ) { + elem.setAttribute( "class", finalValue ); + } + } + } + } + + return this; + }, + + removeClass: function( value ) { + var classes, elem, cur, curValue, clazz, j, finalValue, + i = 0; + + if ( jQuery.isFunction( value ) ) { + return this.each( function( j ) { + jQuery( this ).removeClass( value.call( this, j, getClass( this ) ) ); + } ); + } + + if ( !arguments.length ) { + return this.attr( "class", "" ); + } + + if ( typeof value === "string" && value ) { + classes = value.match( rnotwhite ) || []; + + while ( ( elem = this[ i++ ] ) ) { + curValue = getClass( elem ); + + // This expression is here for better compressibility (see addClass) + cur = elem.nodeType === 1 && + ( " " + curValue + " " ).replace( rclass, " " ); + + if ( cur ) { + j = 0; + while ( ( clazz = classes[ j++ ] ) ) { + + // Remove *all* instances + while ( cur.indexOf( " " + clazz + " " ) > -1 ) { + cur = cur.replace( " " + clazz + " ", " " ); + } + } + + // Only assign if different to avoid unneeded rendering. + finalValue = jQuery.trim( cur ); + if ( curValue !== finalValue ) { + elem.setAttribute( "class", finalValue ); + } + } + } + } + + return this; + }, + + toggleClass: function( value, stateVal ) { + var type = typeof value; + + if ( typeof stateVal === "boolean" && type === "string" ) { + return stateVal ? this.addClass( value ) : this.removeClass( value ); + } + + if ( jQuery.isFunction( value ) ) { + return this.each( function( i ) { + jQuery( this ).toggleClass( + value.call( this, i, getClass( this ), stateVal ), + stateVal + ); + } ); + } + + return this.each( function() { + var className, i, self, classNames; + + if ( type === "string" ) { + + // Toggle individual class names + i = 0; + self = jQuery( this ); + classNames = value.match( rnotwhite ) || []; + + while ( ( className = classNames[ i++ ] ) ) { + + // Check each className given, space separated list + if ( self.hasClass( className ) ) { + self.removeClass( className ); + } else { + self.addClass( className ); + } + } + + // Toggle whole class name + } else if ( value === undefined || type === "boolean" ) { + className = getClass( this ); + if ( className ) { + + // Store className if set + dataPriv.set( this, "__className__", className ); + } + + // If the element has a class name or if we're passed `false`, + // then remove the whole classname (if there was one, the above saved it). + // Otherwise bring back whatever was previously saved (if anything), + // falling back to the empty string if nothing was stored. + if ( this.setAttribute ) { + this.setAttribute( "class", + className || value === false ? + "" : + dataPriv.get( this, "__className__" ) || "" + ); + } + } + } ); + }, + + hasClass: function( selector ) { + var className, elem, + i = 0; + + className = " " + selector + " "; + while ( ( elem = this[ i++ ] ) ) { + if ( elem.nodeType === 1 && + ( " " + getClass( elem ) + " " ).replace( rclass, " " ) + .indexOf( className ) > -1 + ) { + return true; + } + } + + return false; + } +} ); + + + + +var rreturn = /\r/g, + rspaces = /[\x20\t\r\n\f]+/g; + +jQuery.fn.extend( { + val: function( value ) { + var hooks, ret, isFunction, + elem = this[ 0 ]; + + if ( !arguments.length ) { + if ( elem ) { + hooks = jQuery.valHooks[ elem.type ] || + jQuery.valHooks[ elem.nodeName.toLowerCase() ]; + + if ( hooks && + "get" in hooks && + ( ret = hooks.get( elem, "value" ) ) !== undefined + ) { + return ret; + } + + ret = elem.value; + + return typeof ret === "string" ? + + // Handle most common string cases + ret.replace( rreturn, "" ) : + + // Handle cases where value is null/undef or number + ret == null ? "" : ret; + } + + return; + } + + isFunction = jQuery.isFunction( value ); + + return this.each( function( i ) { + var val; + + if ( this.nodeType !== 1 ) { + return; + } + + if ( isFunction ) { + val = value.call( this, i, jQuery( this ).val() ); + } else { + val = value; + } + + // Treat null/undefined as ""; convert numbers to string + if ( val == null ) { + val = ""; + + } else if ( typeof val === "number" ) { + val += ""; + + } else if ( jQuery.isArray( val ) ) { + val = jQuery.map( val, function( value ) { + return value == null ? "" : value + ""; + } ); + } + + hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ]; + + // If set returns undefined, fall back to normal setting + if ( !hooks || !( "set" in hooks ) || hooks.set( this, val, "value" ) === undefined ) { + this.value = val; + } + } ); + } +} ); + +jQuery.extend( { + valHooks: { + option: { + get: function( elem ) { + + var val = jQuery.find.attr( elem, "value" ); + return val != null ? + val : + + // Support: IE <=10 - 11 only + // option.text throws exceptions (#14686, #14858) + // Strip and collapse whitespace + // https://html.spec.whatwg.org/#strip-and-collapse-whitespace + jQuery.trim( jQuery.text( elem ) ).replace( rspaces, " " ); + } + }, + select: { + get: function( elem ) { + var value, option, + options = elem.options, + index = elem.selectedIndex, + one = elem.type === "select-one", + values = one ? null : [], + max = one ? index + 1 : options.length, + i = index < 0 ? + max : + one ? index : 0; + + // Loop through all the selected options + for ( ; i < max; i++ ) { + option = options[ i ]; + + // Support: IE <=9 only + // IE8-9 doesn't update selected after form reset (#2551) + if ( ( option.selected || i === index ) && + + // Don't return options that are disabled or in a disabled optgroup + !option.disabled && + ( !option.parentNode.disabled || + !jQuery.nodeName( option.parentNode, "optgroup" ) ) ) { + + // Get the specific value for the option + value = jQuery( option ).val(); + + // We don't need an array for one selects + if ( one ) { + return value; + } + + // Multi-Selects return an array + values.push( value ); + } + } + + return values; + }, + + set: function( elem, value ) { + var optionSet, option, + options = elem.options, + values = jQuery.makeArray( value ), + i = options.length; + + while ( i-- ) { + option = options[ i ]; + if ( option.selected = + jQuery.inArray( jQuery.valHooks.option.get( option ), values ) > -1 + ) { + optionSet = true; + } + } + + // Force browsers to behave consistently when non-matching value is set + if ( !optionSet ) { + elem.selectedIndex = -1; + } + return values; + } + } + } +} ); + +// Radios and checkboxes getter/setter +jQuery.each( [ "radio", "checkbox" ], function() { + jQuery.valHooks[ this ] = { + set: function( elem, value ) { + if ( jQuery.isArray( value ) ) { + return ( elem.checked = jQuery.inArray( jQuery( elem ).val(), value ) > -1 ); + } + } + }; + if ( !support.checkOn ) { + jQuery.valHooks[ this ].get = function( elem ) { + return elem.getAttribute( "value" ) === null ? "on" : elem.value; + }; + } +} ); + + + + +// Return jQuery for attributes-only inclusion + + +var rfocusMorph = /^(?:focusinfocus|focusoutblur)$/; + +jQuery.extend( jQuery.event, { + + trigger: function( event, data, elem, onlyHandlers ) { + + var i, cur, tmp, bubbleType, ontype, handle, special, + eventPath = [ elem || document ], + type = hasOwn.call( event, "type" ) ? event.type : event, + namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split( "." ) : []; + + cur = tmp = elem = elem || document; + + // Don't do events on text and comment nodes + if ( elem.nodeType === 3 || elem.nodeType === 8 ) { + return; + } + + // focus/blur morphs to focusin/out; ensure we're not firing them right now + if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { + return; + } + + if ( type.indexOf( "." ) > -1 ) { + + // Namespaced trigger; create a regexp to match event type in handle() + namespaces = type.split( "." ); + type = namespaces.shift(); + namespaces.sort(); + } + ontype = type.indexOf( ":" ) < 0 && "on" + type; + + // Caller can pass in a jQuery.Event object, Object, or just an event type string + event = event[ jQuery.expando ] ? + event : + new jQuery.Event( type, typeof event === "object" && event ); + + // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) + event.isTrigger = onlyHandlers ? 2 : 3; + event.namespace = namespaces.join( "." ); + event.rnamespace = event.namespace ? + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ) : + null; + + // Clean up the event in case it is being reused + event.result = undefined; + if ( !event.target ) { + event.target = elem; + } + + // Clone any incoming data and prepend the event, creating the handler arg list + data = data == null ? + [ event ] : + jQuery.makeArray( data, [ event ] ); + + // Allow special events to draw outside the lines + special = jQuery.event.special[ type ] || {}; + if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { + return; + } + + // Determine event propagation path in advance, per W3C events spec (#9951) + // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) + if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { + + bubbleType = special.delegateType || type; + if ( !rfocusMorph.test( bubbleType + type ) ) { + cur = cur.parentNode; + } + for ( ; cur; cur = cur.parentNode ) { + eventPath.push( cur ); + tmp = cur; + } + + // Only add window if we got to document (e.g., not plain obj or detached DOM) + if ( tmp === ( elem.ownerDocument || document ) ) { + eventPath.push( tmp.defaultView || tmp.parentWindow || window ); + } + } + + // Fire handlers on the event path + i = 0; + while ( ( cur = eventPath[ i++ ] ) && !event.isPropagationStopped() ) { + + event.type = i > 1 ? + bubbleType : + special.bindType || type; + + // jQuery handler + handle = ( dataPriv.get( cur, "events" ) || {} )[ event.type ] && + dataPriv.get( cur, "handle" ); + if ( handle ) { + handle.apply( cur, data ); + } + + // Native handler + handle = ontype && cur[ ontype ]; + if ( handle && handle.apply && acceptData( cur ) ) { + event.result = handle.apply( cur, data ); + if ( event.result === false ) { + event.preventDefault(); + } + } + } + event.type = type; + + // If nobody prevented the default action, do it now + if ( !onlyHandlers && !event.isDefaultPrevented() ) { + + if ( ( !special._default || + special._default.apply( eventPath.pop(), data ) === false ) && + acceptData( elem ) ) { + + // Call a native DOM method on the target with the same name as the event. + // Don't do default actions on window, that's where global variables be (#6170) + if ( ontype && jQuery.isFunction( elem[ type ] ) && !jQuery.isWindow( elem ) ) { + + // Don't re-trigger an onFOO event when we call its FOO() method + tmp = elem[ ontype ]; + + if ( tmp ) { + elem[ ontype ] = null; + } + + // Prevent re-triggering of the same event, since we already bubbled it above + jQuery.event.triggered = type; + elem[ type ](); + jQuery.event.triggered = undefined; + + if ( tmp ) { + elem[ ontype ] = tmp; + } + } + } + } + + return event.result; + }, + + // Piggyback on a donor event to simulate a different one + // Used only for `focus(in | out)` events + simulate: function( type, elem, event ) { + var e = jQuery.extend( + new jQuery.Event(), + event, + { + type: type, + isSimulated: true + } + ); + + jQuery.event.trigger( e, null, elem ); + } + +} ); + +jQuery.fn.extend( { + + trigger: function( type, data ) { + return this.each( function() { + jQuery.event.trigger( type, data, this ); + } ); + }, + triggerHandler: function( type, data ) { + var elem = this[ 0 ]; + if ( elem ) { + return jQuery.event.trigger( type, data, elem, true ); + } + } +} ); + + +jQuery.each( ( "blur focus focusin focusout resize scroll click dblclick " + + "mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave " + + "change select submit keydown keypress keyup contextmenu" ).split( " " ), + function( i, name ) { + + // Handle event binding + jQuery.fn[ name ] = function( data, fn ) { + return arguments.length > 0 ? + this.on( name, null, data, fn ) : + this.trigger( name ); + }; +} ); + +jQuery.fn.extend( { + hover: function( fnOver, fnOut ) { + return this.mouseenter( fnOver ).mouseleave( fnOut || fnOver ); + } +} ); + + + + +support.focusin = "onfocusin" in window; + + +// Support: Firefox <=44 +// Firefox doesn't have focus(in | out) events +// Related ticket - https://bugzilla.mozilla.org/show_bug.cgi?id=687787 +// +// Support: Chrome <=48 - 49, Safari <=9.0 - 9.1 +// focus(in | out) events fire after focus & blur events, +// which is spec violation - http://www.w3.org/TR/DOM-Level-3-Events/#events-focusevent-event-order +// Related ticket - https://bugs.chromium.org/p/chromium/issues/detail?id=449857 +if ( !support.focusin ) { + jQuery.each( { focus: "focusin", blur: "focusout" }, function( orig, fix ) { + + // Attach a single capturing handler on the document while someone wants focusin/focusout + var handler = function( event ) { + jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ) ); + }; + + jQuery.event.special[ fix ] = { + setup: function() { + var doc = this.ownerDocument || this, + attaches = dataPriv.access( doc, fix ); + + if ( !attaches ) { + doc.addEventListener( orig, handler, true ); + } + dataPriv.access( doc, fix, ( attaches || 0 ) + 1 ); + }, + teardown: function() { + var doc = this.ownerDocument || this, + attaches = dataPriv.access( doc, fix ) - 1; + + if ( !attaches ) { + doc.removeEventListener( orig, handler, true ); + dataPriv.remove( doc, fix ); + + } else { + dataPriv.access( doc, fix, attaches ); + } + } + }; + } ); +} +var location = window.location; + +var nonce = jQuery.now(); + +var rquery = ( /\?/ ); + + + +// Cross-browser xml parsing +jQuery.parseXML = function( data ) { + var xml; + if ( !data || typeof data !== "string" ) { + return null; + } + + // Support: IE 9 - 11 only + // IE throws on parseFromString with invalid input. + try { + xml = ( new window.DOMParser() ).parseFromString( data, "text/xml" ); + } catch ( e ) { + xml = undefined; + } + + if ( !xml || xml.getElementsByTagName( "parsererror" ).length ) { + jQuery.error( "Invalid XML: " + data ); + } + return xml; +}; + + +var + rbracket = /\[\]$/, + rCRLF = /\r?\n/g, + rsubmitterTypes = /^(?:submit|button|image|reset|file)$/i, + rsubmittable = /^(?:input|select|textarea|keygen)/i; + +function buildParams( prefix, obj, traditional, add ) { + var name; + + if ( jQuery.isArray( obj ) ) { + + // Serialize array item. + jQuery.each( obj, function( i, v ) { + if ( traditional || rbracket.test( prefix ) ) { + + // Treat each array item as a scalar. + add( prefix, v ); + + } else { + + // Item is non-scalar (array or object), encode its numeric index. + buildParams( + prefix + "[" + ( typeof v === "object" && v != null ? i : "" ) + "]", + v, + traditional, + add + ); + } + } ); + + } else if ( !traditional && jQuery.type( obj ) === "object" ) { + + // Serialize object item. + for ( name in obj ) { + buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add ); + } + + } else { + + // Serialize scalar item. + add( prefix, obj ); + } +} + +// Serialize an array of form elements or a set of +// key/values into a query string +jQuery.param = function( a, traditional ) { + var prefix, + s = [], + add = function( key, valueOrFunction ) { + + // If value is a function, invoke it and use its return value + var value = jQuery.isFunction( valueOrFunction ) ? + valueOrFunction() : + valueOrFunction; + + s[ s.length ] = encodeURIComponent( key ) + "=" + + encodeURIComponent( value == null ? "" : value ); + }; + + // If an array was passed in, assume that it is an array of form elements. + if ( jQuery.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) { + + // Serialize the form elements + jQuery.each( a, function() { + add( this.name, this.value ); + } ); + + } else { + + // If traditional, encode the "old" way (the way 1.3.2 or older + // did it), otherwise encode params recursively. + for ( prefix in a ) { + buildParams( prefix, a[ prefix ], traditional, add ); + } + } + + // Return the resulting serialization + return s.join( "&" ); +}; + +jQuery.fn.extend( { + serialize: function() { + return jQuery.param( this.serializeArray() ); + }, + serializeArray: function() { + return this.map( function() { + + // Can add propHook for "elements" to filter or add form elements + var elements = jQuery.prop( this, "elements" ); + return elements ? jQuery.makeArray( elements ) : this; + } ) + .filter( function() { + var type = this.type; + + // Use .is( ":disabled" ) so that fieldset[disabled] works + return this.name && !jQuery( this ).is( ":disabled" ) && + rsubmittable.test( this.nodeName ) && !rsubmitterTypes.test( type ) && + ( this.checked || !rcheckableType.test( type ) ); + } ) + .map( function( i, elem ) { + var val = jQuery( this ).val(); + + return val == null ? + null : + jQuery.isArray( val ) ? + jQuery.map( val, function( val ) { + return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + } ) : + { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + } ).get(); + } +} ); + + +var + r20 = /%20/g, + rhash = /#.*$/, + rts = /([?&])_=[^&]*/, + rheaders = /^(.*?):[ \t]*([^\r\n]*)$/mg, + + // #7653, #8125, #8152: local protocol detection + rlocalProtocol = /^(?:about|app|app-storage|.+-extension|file|res|widget):$/, + rnoContent = /^(?:GET|HEAD)$/, + rprotocol = /^\/\//, + + /* Prefilters + * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example) + * 2) These are called: + * - BEFORE asking for a transport + * - AFTER param serialization (s.data is a string if s.processData is true) + * 3) key is the dataType + * 4) the catchall symbol "*" can be used + * 5) execution will start with transport dataType and THEN continue down to "*" if needed + */ + prefilters = {}, + + /* Transports bindings + * 1) key is the dataType + * 2) the catchall symbol "*" can be used + * 3) selection will start with transport dataType and THEN go to "*" if needed + */ + transports = {}, + + // Avoid comment-prolog char sequence (#10098); must appease lint and evade compression + allTypes = "*/".concat( "*" ), + + // Anchor tag for parsing the document origin + originAnchor = document.createElement( "a" ); + originAnchor.href = location.href; + +// Base "constructor" for jQuery.ajaxPrefilter and jQuery.ajaxTransport +function addToPrefiltersOrTransports( structure ) { + + // dataTypeExpression is optional and defaults to "*" + return function( dataTypeExpression, func ) { + + if ( typeof dataTypeExpression !== "string" ) { + func = dataTypeExpression; + dataTypeExpression = "*"; + } + + var dataType, + i = 0, + dataTypes = dataTypeExpression.toLowerCase().match( rnotwhite ) || []; + + if ( jQuery.isFunction( func ) ) { + + // For each dataType in the dataTypeExpression + while ( ( dataType = dataTypes[ i++ ] ) ) { + + // Prepend if requested + if ( dataType[ 0 ] === "+" ) { + dataType = dataType.slice( 1 ) || "*"; + ( structure[ dataType ] = structure[ dataType ] || [] ).unshift( func ); + + // Otherwise append + } else { + ( structure[ dataType ] = structure[ dataType ] || [] ).push( func ); + } + } + } + }; +} + +// Base inspection function for prefilters and transports +function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR ) { + + var inspected = {}, + seekingTransport = ( structure === transports ); + + function inspect( dataType ) { + var selected; + inspected[ dataType ] = true; + jQuery.each( structure[ dataType ] || [], function( _, prefilterOrFactory ) { + var dataTypeOrTransport = prefilterOrFactory( options, originalOptions, jqXHR ); + if ( typeof dataTypeOrTransport === "string" && + !seekingTransport && !inspected[ dataTypeOrTransport ] ) { + + options.dataTypes.unshift( dataTypeOrTransport ); + inspect( dataTypeOrTransport ); + return false; + } else if ( seekingTransport ) { + return !( selected = dataTypeOrTransport ); + } + } ); + return selected; + } + + return inspect( options.dataTypes[ 0 ] ) || !inspected[ "*" ] && inspect( "*" ); +} + +// A special extend for ajax options +// that takes "flat" options (not to be deep extended) +// Fixes #9887 +function ajaxExtend( target, src ) { + var key, deep, + flatOptions = jQuery.ajaxSettings.flatOptions || {}; + + for ( key in src ) { + if ( src[ key ] !== undefined ) { + ( flatOptions[ key ] ? target : ( deep || ( deep = {} ) ) )[ key ] = src[ key ]; + } + } + if ( deep ) { + jQuery.extend( true, target, deep ); + } + + return target; +} + +/* Handles responses to an ajax request: + * - finds the right dataType (mediates between content-type and expected dataType) + * - returns the corresponding response + */ +function ajaxHandleResponses( s, jqXHR, responses ) { + + var ct, type, finalDataType, firstDataType, + contents = s.contents, + dataTypes = s.dataTypes; + + // Remove auto dataType and get content-type in the process + while ( dataTypes[ 0 ] === "*" ) { + dataTypes.shift(); + if ( ct === undefined ) { + ct = s.mimeType || jqXHR.getResponseHeader( "Content-Type" ); + } + } + + // Check if we're dealing with a known content-type + if ( ct ) { + for ( type in contents ) { + if ( contents[ type ] && contents[ type ].test( ct ) ) { + dataTypes.unshift( type ); + break; + } + } + } + + // Check to see if we have a response for the expected dataType + if ( dataTypes[ 0 ] in responses ) { + finalDataType = dataTypes[ 0 ]; + } else { + + // Try convertible dataTypes + for ( type in responses ) { + if ( !dataTypes[ 0 ] || s.converters[ type + " " + dataTypes[ 0 ] ] ) { + finalDataType = type; + break; + } + if ( !firstDataType ) { + firstDataType = type; + } + } + + // Or just use first one + finalDataType = finalDataType || firstDataType; + } + + // If we found a dataType + // We add the dataType to the list if needed + // and return the corresponding response + if ( finalDataType ) { + if ( finalDataType !== dataTypes[ 0 ] ) { + dataTypes.unshift( finalDataType ); + } + return responses[ finalDataType ]; + } +} + +/* Chain conversions given the request and the original response + * Also sets the responseXXX fields on the jqXHR instance + */ +function ajaxConvert( s, response, jqXHR, isSuccess ) { + var conv2, current, conv, tmp, prev, + converters = {}, + + // Work with a copy of dataTypes in case we need to modify it for conversion + dataTypes = s.dataTypes.slice(); + + // Create converters map with lowercased keys + if ( dataTypes[ 1 ] ) { + for ( conv in s.converters ) { + converters[ conv.toLowerCase() ] = s.converters[ conv ]; + } + } + + current = dataTypes.shift(); + + // Convert to each sequential dataType + while ( current ) { + + if ( s.responseFields[ current ] ) { + jqXHR[ s.responseFields[ current ] ] = response; + } + + // Apply the dataFilter if provided + if ( !prev && isSuccess && s.dataFilter ) { + response = s.dataFilter( response, s.dataType ); + } + + prev = current; + current = dataTypes.shift(); + + if ( current ) { + + // There's only work to do if current dataType is non-auto + if ( current === "*" ) { + + current = prev; + + // Convert response if prev dataType is non-auto and differs from current + } else if ( prev !== "*" && prev !== current ) { + + // Seek a direct converter + conv = converters[ prev + " " + current ] || converters[ "* " + current ]; + + // If none found, seek a pair + if ( !conv ) { + for ( conv2 in converters ) { + + // If conv2 outputs current + tmp = conv2.split( " " ); + if ( tmp[ 1 ] === current ) { + + // If prev can be converted to accepted input + conv = converters[ prev + " " + tmp[ 0 ] ] || + converters[ "* " + tmp[ 0 ] ]; + if ( conv ) { + + // Condense equivalence converters + if ( conv === true ) { + conv = converters[ conv2 ]; + + // Otherwise, insert the intermediate dataType + } else if ( converters[ conv2 ] !== true ) { + current = tmp[ 0 ]; + dataTypes.unshift( tmp[ 1 ] ); + } + break; + } + } + } + } + + // Apply converter (if not an equivalence) + if ( conv !== true ) { + + // Unless errors are allowed to bubble, catch and return them + if ( conv && s.throws ) { + response = conv( response ); + } else { + try { + response = conv( response ); + } catch ( e ) { + return { + state: "parsererror", + error: conv ? e : "No conversion from " + prev + " to " + current + }; + } + } + } + } + } + } + + return { state: "success", data: response }; +} + +jQuery.extend( { + + // Counter for holding the number of active queries + active: 0, + + // Last-Modified header cache for next request + lastModified: {}, + etag: {}, + + ajaxSettings: { + url: location.href, + type: "GET", + isLocal: rlocalProtocol.test( location.protocol ), + global: true, + processData: true, + async: true, + contentType: "application/x-www-form-urlencoded; charset=UTF-8", + /* + timeout: 0, + data: null, + dataType: null, + username: null, + password: null, + cache: null, + throws: false, + traditional: false, + headers: {}, + */ + + accepts: { + "*": allTypes, + text: "text/plain", + html: "text/html", + xml: "application/xml, text/xml", + json: "application/json, text/javascript" + }, + + contents: { + xml: /\bxml\b/, + html: /\bhtml/, + json: /\bjson\b/ + }, + + responseFields: { + xml: "responseXML", + text: "responseText", + json: "responseJSON" + }, + + // Data converters + // Keys separate source (or catchall "*") and destination types with a single space + converters: { + + // Convert anything to text + "* text": String, + + // Text to html (true = no transformation) + "text html": true, + + // Evaluate text as a json expression + "text json": JSON.parse, + + // Parse text as xml + "text xml": jQuery.parseXML + }, + + // For options that shouldn't be deep extended: + // you can add your own custom options here if + // and when you create one that shouldn't be + // deep extended (see ajaxExtend) + flatOptions: { + url: true, + context: true + } + }, + + // Creates a full fledged settings object into target + // with both ajaxSettings and settings fields. + // If target is omitted, writes into ajaxSettings. + ajaxSetup: function( target, settings ) { + return settings ? + + // Building a settings object + ajaxExtend( ajaxExtend( target, jQuery.ajaxSettings ), settings ) : + + // Extending ajaxSettings + ajaxExtend( jQuery.ajaxSettings, target ); + }, + + ajaxPrefilter: addToPrefiltersOrTransports( prefilters ), + ajaxTransport: addToPrefiltersOrTransports( transports ), + + // Main method + ajax: function( url, options ) { + + // If url is an object, simulate pre-1.5 signature + if ( typeof url === "object" ) { + options = url; + url = undefined; + } + + // Force options to be an object + options = options || {}; + + var transport, + + // URL without anti-cache param + cacheURL, + + // Response headers + responseHeadersString, + responseHeaders, + + // timeout handle + timeoutTimer, + + // Url cleanup var + urlAnchor, + + // Request state (becomes false upon send and true upon completion) + completed, + + // To know if global events are to be dispatched + fireGlobals, + + // Loop variable + i, + + // uncached part of the url + uncached, + + // Create the final options object + s = jQuery.ajaxSetup( {}, options ), + + // Callbacks context + callbackContext = s.context || s, + + // Context for global events is callbackContext if it is a DOM node or jQuery collection + globalEventContext = s.context && + ( callbackContext.nodeType || callbackContext.jquery ) ? + jQuery( callbackContext ) : + jQuery.event, + + // Deferreds + deferred = jQuery.Deferred(), + completeDeferred = jQuery.Callbacks( "once memory" ), + + // Status-dependent callbacks + statusCode = s.statusCode || {}, + + // Headers (they are sent all at once) + requestHeaders = {}, + requestHeadersNames = {}, + + // Default abort message + strAbort = "canceled", + + // Fake xhr + jqXHR = { + readyState: 0, + + // Builds headers hashtable if needed + getResponseHeader: function( key ) { + var match; + if ( completed ) { + if ( !responseHeaders ) { + responseHeaders = {}; + while ( ( match = rheaders.exec( responseHeadersString ) ) ) { + responseHeaders[ match[ 1 ].toLowerCase() ] = match[ 2 ]; + } + } + match = responseHeaders[ key.toLowerCase() ]; + } + return match == null ? null : match; + }, + + // Raw string + getAllResponseHeaders: function() { + return completed ? responseHeadersString : null; + }, + + // Caches the header + setRequestHeader: function( name, value ) { + if ( completed == null ) { + name = requestHeadersNames[ name.toLowerCase() ] = + requestHeadersNames[ name.toLowerCase() ] || name; + requestHeaders[ name ] = value; + } + return this; + }, + + // Overrides response content-type header + overrideMimeType: function( type ) { + if ( completed == null ) { + s.mimeType = type; + } + return this; + }, + + // Status-dependent callbacks + statusCode: function( map ) { + var code; + if ( map ) { + if ( completed ) { + + // Execute the appropriate callbacks + jqXHR.always( map[ jqXHR.status ] ); + } else { + + // Lazy-add the new callbacks in a way that preserves old ones + for ( code in map ) { + statusCode[ code ] = [ statusCode[ code ], map[ code ] ]; + } + } + } + return this; + }, + + // Cancel the request + abort: function( statusText ) { + var finalText = statusText || strAbort; + if ( transport ) { + transport.abort( finalText ); + } + done( 0, finalText ); + return this; + } + }; + + // Attach deferreds + deferred.promise( jqXHR ); + + // Add protocol if not provided (prefilters might expect it) + // Handle falsy url in the settings object (#10093: consistency with old signature) + // We also use the url parameter if available + s.url = ( ( url || s.url || location.href ) + "" ) + .replace( rprotocol, location.protocol + "//" ); + + // Alias method option to type as per ticket #12004 + s.type = options.method || options.type || s.method || s.type; + + // Extract dataTypes list + s.dataTypes = ( s.dataType || "*" ).toLowerCase().match( rnotwhite ) || [ "" ]; + + // A cross-domain request is in order when the origin doesn't match the current origin. + if ( s.crossDomain == null ) { + urlAnchor = document.createElement( "a" ); + + // Support: IE <=8 - 11, Edge 12 - 13 + // IE throws exception on accessing the href property if url is malformed, + // e.g. http://example.com:80x/ + try { + urlAnchor.href = s.url; + + // Support: IE <=8 - 11 only + // Anchor's host property isn't correctly set when s.url is relative + urlAnchor.href = urlAnchor.href; + s.crossDomain = originAnchor.protocol + "//" + originAnchor.host !== + urlAnchor.protocol + "//" + urlAnchor.host; + } catch ( e ) { + + // If there is an error parsing the URL, assume it is crossDomain, + // it can be rejected by the transport if it is invalid + s.crossDomain = true; + } + } + + // Convert data if not already a string + if ( s.data && s.processData && typeof s.data !== "string" ) { + s.data = jQuery.param( s.data, s.traditional ); + } + + // Apply prefilters + inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); + + // If request was aborted inside a prefilter, stop there + if ( completed ) { + return jqXHR; + } + + // We can fire global events as of now if asked to + // Don't fire events if jQuery.event is undefined in an AMD-usage scenario (#15118) + fireGlobals = jQuery.event && s.global; + + // Watch for a new set of requests + if ( fireGlobals && jQuery.active++ === 0 ) { + jQuery.event.trigger( "ajaxStart" ); + } + + // Uppercase the type + s.type = s.type.toUpperCase(); + + // Determine if request has content + s.hasContent = !rnoContent.test( s.type ); + + // Save the URL in case we're toying with the If-Modified-Since + // and/or If-None-Match header later on + // Remove hash to simplify url manipulation + cacheURL = s.url.replace( rhash, "" ); + + // More options handling for requests with no content + if ( !s.hasContent ) { + + // Remember the hash so we can put it back + uncached = s.url.slice( cacheURL.length ); + + // If data is available, append data to url + if ( s.data ) { + cacheURL += ( rquery.test( cacheURL ) ? "&" : "?" ) + s.data; + + // #9682: remove data so that it's not used in an eventual retry + delete s.data; + } + + // Add anti-cache in uncached url if needed + if ( s.cache === false ) { + cacheURL = cacheURL.replace( rts, "" ); + uncached = ( rquery.test( cacheURL ) ? "&" : "?" ) + "_=" + ( nonce++ ) + uncached; + } + + // Put hash and anti-cache on the URL that will be requested (gh-1732) + s.url = cacheURL + uncached; + + // Change '%20' to '+' if this is encoded form body content (gh-2658) + } else if ( s.data && s.processData && + ( s.contentType || "" ).indexOf( "application/x-www-form-urlencoded" ) === 0 ) { + s.data = s.data.replace( r20, "+" ); + } + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + if ( jQuery.lastModified[ cacheURL ] ) { + jqXHR.setRequestHeader( "If-Modified-Since", jQuery.lastModified[ cacheURL ] ); + } + if ( jQuery.etag[ cacheURL ] ) { + jqXHR.setRequestHeader( "If-None-Match", jQuery.etag[ cacheURL ] ); + } + } + + // Set the correct header, if data is being sent + if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) { + jqXHR.setRequestHeader( "Content-Type", s.contentType ); + } + + // Set the Accepts header for the server, depending on the dataType + jqXHR.setRequestHeader( + "Accept", + s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[ 0 ] ] ? + s.accepts[ s.dataTypes[ 0 ] ] + + ( s.dataTypes[ 0 ] !== "*" ? ", " + allTypes + "; q=0.01" : "" ) : + s.accepts[ "*" ] + ); + + // Check for headers option + for ( i in s.headers ) { + jqXHR.setRequestHeader( i, s.headers[ i ] ); + } + + // Allow custom headers/mimetypes and early abort + if ( s.beforeSend && + ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || completed ) ) { + + // Abort if not done already and return + return jqXHR.abort(); + } + + // Aborting is no longer a cancellation + strAbort = "abort"; + + // Install callbacks on deferreds + completeDeferred.add( s.complete ); + jqXHR.done( s.success ); + jqXHR.fail( s.error ); + + // Get transport + transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR ); + + // If no transport, we auto-abort + if ( !transport ) { + done( -1, "No Transport" ); + } else { + jqXHR.readyState = 1; + + // Send global event + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxSend", [ jqXHR, s ] ); + } + + // If request was aborted inside ajaxSend, stop there + if ( completed ) { + return jqXHR; + } + + // Timeout + if ( s.async && s.timeout > 0 ) { + timeoutTimer = window.setTimeout( function() { + jqXHR.abort( "timeout" ); + }, s.timeout ); + } + + try { + completed = false; + transport.send( requestHeaders, done ); + } catch ( e ) { + + // Rethrow post-completion exceptions + if ( completed ) { + throw e; + } + + // Propagate others as results + done( -1, e ); + } + } + + // Callback for when everything is done + function done( status, nativeStatusText, responses, headers ) { + var isSuccess, success, error, response, modified, + statusText = nativeStatusText; + + // Ignore repeat invocations + if ( completed ) { + return; + } + + completed = true; + + // Clear timeout if it exists + if ( timeoutTimer ) { + window.clearTimeout( timeoutTimer ); + } + + // Dereference transport for early garbage collection + // (no matter how long the jqXHR object will be used) + transport = undefined; + + // Cache response headers + responseHeadersString = headers || ""; + + // Set readyState + jqXHR.readyState = status > 0 ? 4 : 0; + + // Determine if successful + isSuccess = status >= 200 && status < 300 || status === 304; + + // Get response data + if ( responses ) { + response = ajaxHandleResponses( s, jqXHR, responses ); + } + + // Convert no matter what (that way responseXXX fields are always set) + response = ajaxConvert( s, response, jqXHR, isSuccess ); + + // If successful, handle type chaining + if ( isSuccess ) { + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + modified = jqXHR.getResponseHeader( "Last-Modified" ); + if ( modified ) { + jQuery.lastModified[ cacheURL ] = modified; + } + modified = jqXHR.getResponseHeader( "etag" ); + if ( modified ) { + jQuery.etag[ cacheURL ] = modified; + } + } + + // if no content + if ( status === 204 || s.type === "HEAD" ) { + statusText = "nocontent"; + + // if not modified + } else if ( status === 304 ) { + statusText = "notmodified"; + + // If we have data, let's convert it + } else { + statusText = response.state; + success = response.data; + error = response.error; + isSuccess = !error; + } + } else { + + // Extract error from statusText and normalize for non-aborts + error = statusText; + if ( status || !statusText ) { + statusText = "error"; + if ( status < 0 ) { + status = 0; + } + } + } + + // Set data for the fake xhr object + jqXHR.status = status; + jqXHR.statusText = ( nativeStatusText || statusText ) + ""; + + // Success/Error + if ( isSuccess ) { + deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); + } else { + deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); + } + + // Status-dependent callbacks + jqXHR.statusCode( statusCode ); + statusCode = undefined; + + if ( fireGlobals ) { + globalEventContext.trigger( isSuccess ? "ajaxSuccess" : "ajaxError", + [ jqXHR, s, isSuccess ? success : error ] ); + } + + // Complete + completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] ); + + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxComplete", [ jqXHR, s ] ); + + // Handle the global AJAX counter + if ( !( --jQuery.active ) ) { + jQuery.event.trigger( "ajaxStop" ); + } + } + } + + return jqXHR; + }, + + getJSON: function( url, data, callback ) { + return jQuery.get( url, data, callback, "json" ); + }, + + getScript: function( url, callback ) { + return jQuery.get( url, undefined, callback, "script" ); + } +} ); + +jQuery.each( [ "get", "post" ], function( i, method ) { + jQuery[ method ] = function( url, data, callback, type ) { + + // Shift arguments if data argument was omitted + if ( jQuery.isFunction( data ) ) { + type = type || callback; + callback = data; + data = undefined; + } + + // The url can be an options object (which then must have .url) + return jQuery.ajax( jQuery.extend( { + url: url, + type: method, + dataType: type, + data: data, + success: callback + }, jQuery.isPlainObject( url ) && url ) ); + }; +} ); + + +jQuery._evalUrl = function( url ) { + return jQuery.ajax( { + url: url, + + // Make this explicit, since user can override this through ajaxSetup (#11264) + type: "GET", + dataType: "script", + cache: true, + async: false, + global: false, + "throws": true + } ); +}; + + +jQuery.fn.extend( { + wrapAll: function( html ) { + var wrap; + + if ( this[ 0 ] ) { + if ( jQuery.isFunction( html ) ) { + html = html.call( this[ 0 ] ); + } + + // The elements to wrap the target around + wrap = jQuery( html, this[ 0 ].ownerDocument ).eq( 0 ).clone( true ); + + if ( this[ 0 ].parentNode ) { + wrap.insertBefore( this[ 0 ] ); + } + + wrap.map( function() { + var elem = this; + + while ( elem.firstElementChild ) { + elem = elem.firstElementChild; + } + + return elem; + } ).append( this ); + } + + return this; + }, + + wrapInner: function( html ) { + if ( jQuery.isFunction( html ) ) { + return this.each( function( i ) { + jQuery( this ).wrapInner( html.call( this, i ) ); + } ); + } + + return this.each( function() { + var self = jQuery( this ), + contents = self.contents(); + + if ( contents.length ) { + contents.wrapAll( html ); + + } else { + self.append( html ); + } + } ); + }, + + wrap: function( html ) { + var isFunction = jQuery.isFunction( html ); + + return this.each( function( i ) { + jQuery( this ).wrapAll( isFunction ? html.call( this, i ) : html ); + } ); + }, + + unwrap: function( selector ) { + this.parent( selector ).not( "body" ).each( function() { + jQuery( this ).replaceWith( this.childNodes ); + } ); + return this; + } +} ); + + +jQuery.expr.pseudos.hidden = function( elem ) { + return !jQuery.expr.pseudos.visible( elem ); +}; +jQuery.expr.pseudos.visible = function( elem ) { + return !!( elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length ); +}; + + + + +jQuery.ajaxSettings.xhr = function() { + try { + return new window.XMLHttpRequest(); + } catch ( e ) {} +}; + +var xhrSuccessStatus = { + + // File protocol always yields status code 0, assume 200 + 0: 200, + + // Support: IE <=9 only + // #1450: sometimes IE returns 1223 when it should be 204 + 1223: 204 + }, + xhrSupported = jQuery.ajaxSettings.xhr(); + +support.cors = !!xhrSupported && ( "withCredentials" in xhrSupported ); +support.ajax = xhrSupported = !!xhrSupported; + +jQuery.ajaxTransport( function( options ) { + var callback, errorCallback; + + // Cross domain only allowed if supported through XMLHttpRequest + if ( support.cors || xhrSupported && !options.crossDomain ) { + return { + send: function( headers, complete ) { + var i, + xhr = options.xhr(); + + xhr.open( + options.type, + options.url, + options.async, + options.username, + options.password + ); + + // Apply custom fields if provided + if ( options.xhrFields ) { + for ( i in options.xhrFields ) { + xhr[ i ] = options.xhrFields[ i ]; + } + } + + // Override mime type if needed + if ( options.mimeType && xhr.overrideMimeType ) { + xhr.overrideMimeType( options.mimeType ); + } + + // X-Requested-With header + // For cross-domain requests, seeing as conditions for a preflight are + // akin to a jigsaw puzzle, we simply never set it to be sure. + // (it can always be set on a per-request basis or even using ajaxSetup) + // For same-domain requests, won't change header if already provided. + if ( !options.crossDomain && !headers[ "X-Requested-With" ] ) { + headers[ "X-Requested-With" ] = "XMLHttpRequest"; + } + + // Set headers + for ( i in headers ) { + xhr.setRequestHeader( i, headers[ i ] ); + } + + // Callback + callback = function( type ) { + return function() { + if ( callback ) { + callback = errorCallback = xhr.onload = + xhr.onerror = xhr.onabort = xhr.onreadystatechange = null; + + if ( type === "abort" ) { + xhr.abort(); + } else if ( type === "error" ) { + + // Support: IE <=9 only + // On a manual native abort, IE9 throws + // errors on any property access that is not readyState + if ( typeof xhr.status !== "number" ) { + complete( 0, "error" ); + } else { + complete( + + // File: protocol always yields status 0; see #8605, #14207 + xhr.status, + xhr.statusText + ); + } + } else { + complete( + xhrSuccessStatus[ xhr.status ] || xhr.status, + xhr.statusText, + + // Support: IE <=9 only + // IE9 has no XHR2 but throws on binary (trac-11426) + // For XHR2 non-text, let the caller handle it (gh-2498) + ( xhr.responseType || "text" ) !== "text" || + typeof xhr.responseText !== "string" ? + { binary: xhr.response } : + { text: xhr.responseText }, + xhr.getAllResponseHeaders() + ); + } + } + }; + }; + + // Listen to events + xhr.onload = callback(); + errorCallback = xhr.onerror = callback( "error" ); + + // Support: IE 9 only + // Use onreadystatechange to replace onabort + // to handle uncaught aborts + if ( xhr.onabort !== undefined ) { + xhr.onabort = errorCallback; + } else { + xhr.onreadystatechange = function() { + + // Check readyState before timeout as it changes + if ( xhr.readyState === 4 ) { + + // Allow onerror to be called first, + // but that will not handle a native abort + // Also, save errorCallback to a variable + // as xhr.onerror cannot be accessed + window.setTimeout( function() { + if ( callback ) { + errorCallback(); + } + } ); + } + }; + } + + // Create the abort callback + callback = callback( "abort" ); + + try { + + // Do send the request (this may raise an exception) + xhr.send( options.hasContent && options.data || null ); + } catch ( e ) { + + // #14683: Only rethrow if this hasn't been notified as an error yet + if ( callback ) { + throw e; + } + } + }, + + abort: function() { + if ( callback ) { + callback(); + } + } + }; + } +} ); + + + + +// Prevent auto-execution of scripts when no explicit dataType was provided (See gh-2432) +jQuery.ajaxPrefilter( function( s ) { + if ( s.crossDomain ) { + s.contents.script = false; + } +} ); + +// Install script dataType +jQuery.ajaxSetup( { + accepts: { + script: "text/javascript, application/javascript, " + + "application/ecmascript, application/x-ecmascript" + }, + contents: { + script: /\b(?:java|ecma)script\b/ + }, + converters: { + "text script": function( text ) { + jQuery.globalEval( text ); + return text; + } + } +} ); + +// Handle cache's special case and crossDomain +jQuery.ajaxPrefilter( "script", function( s ) { + if ( s.cache === undefined ) { + s.cache = false; + } + if ( s.crossDomain ) { + s.type = "GET"; + } +} ); + +// Bind script tag hack transport +jQuery.ajaxTransport( "script", function( s ) { + + // This transport only deals with cross domain requests + if ( s.crossDomain ) { + var script, callback; + return { + send: function( _, complete ) { + script = jQuery( " + empty
', - wantAlines: ['+5'], + wantLineAttribs: ['+5'], wantText: ['empty'], }, - { + lineWithMultipleSpaces: { description: 'Multiple spaces should be preserved', html: 'Text with more than one space.
', - wantAlines: ['+10'], + wantLineAttribs: ['+10'], wantText: ['Text with more than one space.'], }, - { + lineWithMultipleNonBreakingAndNormalSpaces: { description: 'non-breaking and normal space should be preserved', html: 'Text with  more   than  one space.
', - wantAlines: ['+10'], + wantLineAttribs: ['+10'], wantText: ['Text with more than one space.'], }, - { + multiplenbsp: { description: 'Multiple nbsp should be preserved', html: '  
', - wantAlines: ['+2'], + wantLineAttribs: ['+2'], wantText: [' '], }, - { + multipleNonBreakingSpaceBetweenWords: { description: 'Multiple nbsp between words ', html: '  word1  word2   word3
', - wantAlines: ['+m'], + wantLineAttribs: ['+m'], wantText: [' word1 word2 word3'], }, - { + nonBreakingSpacePreceededBySpaceBetweenWords: { description: 'A non-breaking space preceded by a normal space', html: '  word1  word2  word3
', - wantAlines: ['+l'], + wantLineAttribs: ['+l'], wantText: [' word1 word2 word3'], }, - { + nonBreakingSpaceFollowededBySpaceBetweenWords: { description: 'A non-breaking space followed by a normal space', html: '  word1  word2  word3
', - wantAlines: ['+l'], + wantLineAttribs: ['+l'], wantText: [' word1 word2 word3'], }, - { + spacesAfterNewline: { description: 'Don\'t collapse spaces that follow a newline', html: 'something
something
', - wantAlines: ['+9', '+m'], + wantLineAttribs: ['+9', '+m'], wantText: ['something', ' something'], }, - { + spacesAfterNewlineP: { description: 'Don\'t collapse spaces that follow a empty paragraph', html: 'something

something
', - wantAlines: ['+9', '', '+m'], + wantLineAttribs: ['+9', '', '+m'], wantText: ['something', '', ' something'], }, - { + spacesAtEndOfLine: { description: 'Don\'t collapse spaces that preceed/follow a newline', html: 'something
something
', - wantAlines: ['+l', '+m'], + wantLineAttribs: ['+l', '+m'], wantText: ['something ', ' something'], }, - { + spacesAtEndOfLineP: { description: 'Don\'t collapse spaces that preceed/follow a empty paragraph', html: 'something

something
', - wantAlines: ['+l', '', '+m'], + wantLineAttribs: ['+l', '', '+m'], wantText: ['something ', '', ' something'], }, - { + nonBreakingSpacesAfterNewlines: { description: 'Don\'t collapse non-breaking spaces that follow a newline', html: 'something
   something
', - wantAlines: ['+9', '+c'], + wantLineAttribs: ['+9', '+c'], wantText: ['something', ' something'], }, - { + nonBreakingSpacesAfterNewlinesP: { description: 'Don\'t collapse non-breaking spaces that follow a paragraph', html: 'something

   something
', - wantAlines: ['+9', '', '+c'], + wantLineAttribs: ['+9', '', '+c'], wantText: ['something', '', ' something'], }, - { + preserveSpacesInsideElements: { description: 'Preserve all spaces when multiple are present', html: 'Need more space s !
', - wantAlines: ['+h*1+4+2'], + wantLineAttribs: ['+h*0+4+2'], wantText: ['Need more space s !'], }, - { + preserveSpacesAcrossNewlines: { description: 'Newlines and multiple spaces across newlines should be preserved', html: ` Need @@ -261,25 +201,25 @@ const testCases = [ space s !
`, - wantAlines: ['+19*1+4+b'], + wantLineAttribs: ['+19*0+4+b'], wantText: ['Need more space s !'], }, - { + multipleNewLinesAtBeginning: { description: 'Multiple new lines at the beginning should be preserved', html: '

first line

second line
', - wantAlines: ['', '', '', '', '+a', '', '+b'], + wantLineAttribs: ['', '', '', '', '+a', '', '+b'], wantText: ['', '', '', '', 'first line', '', 'second line'], }, - { + multiLineParagraph: { description: 'A paragraph with multiple lines should not loose spaces when lines are combined', html: `

а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь

`, - wantAlines: ['+1t'], + wantLineAttribs: ['+1t'], wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'], }, - { + multiLineParagraphWithPre: { description: 'lines in preformatted text should be kept intact', html: `

а б в г ґ д е є ж з и і ї й к л м н о

multiple
@@ -289,7 +229,7 @@ pre
 

п р с т у ф х ц ч ш щ ю я ь

`, - wantAlines: ['+11', '+8', '+5', '+2', '+3', '+r'], + wantLineAttribs: ['+11', '+8', '+5', '+2', '+3', '+r'], wantText: [ 'а б в г ґ д е є ж з и і ї й к л м н о', 'multiple', @@ -299,95 +239,87 @@ pre 'п р с т у ф х ц ч ш щ ю я ь', ], }, - { + preIntroducesASpace: { description: 'pre should be on a new line not preceded by a space', html: `

1

preline
 
`, - wantAlines: ['+6', '+7'], + wantLineAttribs: ['+6', '+7'], wantText: [' 1 ', 'preline'], }, - { + dontDeleteSpaceInsideElements: { description: 'Preserve spaces on the beginning and end of a element', html: 'Need more space s !
', - wantAlines: ['+f*1+3+1'], + wantLineAttribs: ['+f*0+3+1'], wantText: ['Need more space s !'], }, - { + dontDeleteSpaceOutsideElements: { description: 'Preserve spaces outside elements', html: 'Need more space s !
', - wantAlines: ['+g*1+1+2'], + wantLineAttribs: ['+g*0+1+2'], wantText: ['Need more space s !'], }, - { + dontDeleteSpaceAtEndOfElement: { description: 'Preserve spaces at the end of an element', html: 'Need more space s !
', - wantAlines: ['+g*1+2+1'], + wantLineAttribs: ['+g*0+2+1'], wantText: ['Need more space s !'], }, - { + dontDeleteSpaceAtBeginOfElements: { description: 'Preserve spaces at the start of an element', html: 'Need more space s !
', - wantAlines: ['+f*1+2+2'], + wantLineAttribs: ['+f*0+2+2'], wantText: ['Need more space s !'], }, -]; +}; describe(__filename, function () { - for (const tc of testCases) { - describe(tc.description, function () { - let apool: AttributePool; - let result: { - lines: string[], - lineAttribs: string[], - }; + for (const test of Object.keys(tests)) { + const testObj = tests[test]; + describe(test, function () { + if (testObj.disabled) { + return xit('DISABLED:', test, function (done) { + done(); + }); + } - before(async function () { - if (tc.disabled) return this.skip(); - const {window: {document}} = new jsdom.JSDOM(tc.html); - apool = new AttributePool(); - // To reduce test fragility, the attribute pool is seeded with `knownAttribs`, and all - // attributes in `tc.wantAlines` must be in `knownAttribs`. (This guarantees that attribute - // numbers do not change if the attribute processing code changes.) - for (const attrib of knownAttribs) apool.putAttrib(attrib); - for (const aline of tc.wantAlines) { - for (const op of Changeset.deserializeOps(aline)) { - for (const n of attributes.decodeAttribString(op.attribs)) { - assert(n < knownAttribs.length); - } - } - } + it(testObj.description, async function () { + this.timeout(250); + const $ = cheerio.load(testObj.html); // Load HTML into Cheerio + const doc = $('body')[0]; // Creates a dom-like representation of HTML + // Create an empty attribute pool + const apool = new AttributePool(); + // Convert a dom tree into a list of lines and attribute liens + // using the content collector object const cc = contentcollector.makeContentCollector(true, null, apool); - cc.collectContent(document.body); - result = cc.finish(); - }); + cc.collectContent(doc); + const result = cc.finish(); + const gotAttributes = result.lineAttribs; + const wantAttributes = testObj.wantLineAttribs; + const gotText = new Array(result.lines); + const wantText = testObj.wantText; - it('text matches', async function () { - assert.deepEqual(result.lines, tc.wantText); - }); - - it('alines match', async function () { - assert.deepEqual(result.lineAttribs, tc.wantAlines); - }); - - it('attributes are sorted in canonical order', async function () { - const gotAttribs:string[][][] = []; - const wantAttribs = []; - for (const aline of result.lineAttribs) { - const gotAlineAttribs:string[][] = []; - gotAttribs.push(gotAlineAttribs); - const wantAlineAttribs:Attribute[] = []; - wantAttribs.push(wantAlineAttribs); - for (const op of Changeset.deserializeOps(aline)) { - const gotOpAttribs = [...attributes.attribsFromString(op.attribs, apool)] as unknown as Attribute; - gotAlineAttribs.push(gotOpAttribs); - // @ts-ignore - wantAlineAttribs.push(attributes.sort([...gotOpAttribs])); - } - } - assert.deepEqual(gotAttribs, wantAttribs); + assert.deepEqual(gotText[0], wantText); + assert.deepEqual(gotAttributes, wantAttributes); }); }); } }); + + +function arraysEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return false; + if (a.length !== b.length) return false; + + // If you don't care about the order of the elements inside + // the array, you should sort both arrays here. + // Please note that calling sort on an array will modify that array. + // you might want to clone your array first. + + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/src/tests/backend/specs/crypto.ts b/src/tests/backend/specs/crypto.ts deleted file mode 100644 index 62d79f1b3..000000000 --- a/src/tests/backend/specs/crypto.ts +++ /dev/null @@ -1,10 +0,0 @@ -'use strict'; - - -import {Buffer} from 'buffer'; -import nodeCrypto from 'crypto'; -import util from 'util'; - -const nodeHkdf = nodeCrypto.hkdf ? util.promisify(nodeCrypto.hkdf) : null; - -const ab2hex = (ab:string) => Buffer.from(ab).toString('hex'); diff --git a/src/tests/backend/specs/export.ts b/src/tests/backend/specs/export.ts deleted file mode 100644 index de436f88c..000000000 --- a/src/tests/backend/specs/export.ts +++ /dev/null @@ -1,28 +0,0 @@ -'use strict'; - -import {MapArrayType} from "../../../node/types/MapType"; - -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const settings = require('../../../node/utils/Settings'); - -describe(__filename, function () { - let agent:any; - const settingsBackup:MapArrayType = {}; - - before(async function () { - agent = await common.init(); - settingsBackup.soffice = settings.soffice; - await padManager.getPad('testExportPad', 'test content'); - }); - - after(async function () { - Object.assign(settings, settingsBackup); - }); - - it('returns 500 on export error', async function () { - settings.soffice = 'false'; // '/bin/false' doesn't work on Windows - await agent.get('/p/testExportPad/export/doc') - .expect(500); - }); -}); diff --git a/src/tests/backend/specs/favicon-test-custom.png b/src/tests/backend/specs/favicon-test-custom.png deleted file mode 100644 index 9c6532c96..000000000 Binary files a/src/tests/backend/specs/favicon-test-custom.png and /dev/null differ diff --git a/src/tests/backend/specs/favicon-test-skin.png b/src/tests/backend/specs/favicon-test-skin.png deleted file mode 100644 index 87bdadbbb..000000000 Binary files a/src/tests/backend/specs/favicon-test-skin.png and /dev/null differ diff --git a/src/tests/backend/specs/favicon.ts b/src/tests/backend/specs/favicon.ts deleted file mode 100644 index 6b6230b4b..000000000 --- a/src/tests/backend/specs/favicon.ts +++ /dev/null @@ -1,99 +0,0 @@ -'use strict'; - -import {MapArrayType} from "../../../node/types/MapType"; - -const assert = require('assert').strict; -const common = require('../common'); -const fs = require('fs'); -const fsp = fs.promises; -const path = require('path'); -const settings = require('../../../node/utils/Settings'); -const superagent = require('superagent'); - -describe(__filename, function () { - let agent:any; - let backupSettings:MapArrayType; - let skinDir: string; - let wantCustomIcon: boolean; - let wantDefaultIcon: boolean; - let wantSkinIcon: boolean; - - before(async function () { - agent = await common.init(); - wantCustomIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-custom.png')); - wantDefaultIcon = await fsp.readFile(path.join(settings.root, 'src', 'static', 'favicon.ico')); - wantSkinIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-skin.png')); - }); - - beforeEach(async function () { - backupSettings = {...settings}; - skinDir = await fsp.mkdtemp(path.join(settings.root, 'src', 'static', 'skins', 'test-')); - settings.skinName = path.basename(skinDir); - }); - - afterEach(async function () { - delete settings.favicon; - delete settings.skinName; - Object.assign(settings, backupSettings); - try { - // TODO: The {recursive: true} option wasn't added to fsp.rmdir() until Node.js v12.10.0 so we - // can't rely on it until support for Node.js v10 is dropped. - await fsp.unlink(path.join(skinDir, 'favicon.ico')); - await fsp.rmdir(skinDir, {recursive: true}); - } catch (err) { /* intentionally ignored */ } - }); - - it('uses custom favicon if set (relative pathname)', async function () { - settings.favicon = - path.relative(settings.root, path.join(__dirname, 'favicon-test-custom.png')); - assert(!path.isAbsolute(settings.favicon)); - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantCustomIcon)); - }); - - it('uses custom favicon from url', async function () { - settings.favicon = 'https://etherpad.org/favicon.ico'; - await agent.get('/favicon.ico') - .expect(302); - }); - - it('uses custom favicon if set (absolute pathname)', async function () { - settings.favicon = path.join(__dirname, 'favicon-test-custom.png'); - assert(path.isAbsolute(settings.favicon)); - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantCustomIcon)); - }); - - it('falls back if custom favicon is missing', async function () { - // The previous default for settings.favicon was 'favicon.ico', so many users will continue to - // have that in their settings.json for a long time. There is unlikely to be a favicon at - // path.resolve(settings.root, 'favicon.ico'), so this test ensures that 'favicon.ico' won't be - // a problem for those users. - settings.favicon = 'favicon.ico'; - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantDefaultIcon)); - }); - - it('uses skin favicon if present', async function () { - await fsp.writeFile(path.join(skinDir, 'favicon.ico'), wantSkinIcon); - settings.favicon = null; - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantSkinIcon)); - }); - - it('falls back to default favicon', async function () { - settings.favicon = null; - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantDefaultIcon)); - }); -}); diff --git a/src/tests/backend/specs/health.ts b/src/tests/backend/specs/health.ts deleted file mode 100644 index 97364a7e5..000000000 --- a/src/tests/backend/specs/health.ts +++ /dev/null @@ -1,58 +0,0 @@ -'use strict'; - -import {MapArrayType} from "../../../node/types/MapType"; - -const assert = require('assert').strict; -const common = require('../common'); -const settings = require('../../../node/utils/Settings'); -const superagent = require('superagent'); - -describe(__filename, function () { - let agent:any; - const backup:MapArrayType = {}; - - const getHealth = () => agent.get('/health') - .accept('application/health+json') - .buffer(true) - .parse(superagent.parse['application/json']) - .expect(200) - .expect((res:any) => assert.equal(res.type, 'application/health+json')); - - before(async function () { - agent = await common.init(); - }); - - beforeEach(async function () { - backup.settings = {}; - for (const setting of ['requireAuthentication', 'requireAuthorization']) { - backup.settings[setting] = settings[setting]; - } - }); - - afterEach(async function () { - Object.assign(settings, backup.settings); - }); - - it('/health works', async function () { - const res = await getHealth(); - assert.equal(res.body.status, 'pass'); - assert.equal(res.body.releaseId, settings.getEpVersion()); - }); - - it('auth is not required', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - const res = await getHealth(); - assert.equal(res.body.status, 'pass'); - }); - - // We actually want to test that no express-session state is created, but that is difficult to do - // without intrusive changes or unpleasant ueberdb digging. Instead, we assume that the lack of a - // cookie means that no express-session state was created (how would express-session look up the - // session state if no ID was returned to the client?). - it('no cookie is returned', async function () { - const res = await getHealth(); - const cookie = res.headers['set-cookie']; - assert(cookie == null, `unexpected Set-Cookie: ${cookie}`); - }); -}); diff --git a/src/tests/backend/specs/hooks.ts b/src/tests/backend/specs/hooks.js similarity index 81% rename from src/tests/backend/specs/hooks.ts rename to src/tests/backend/specs/hooks.js index 07c6e262e..9dbf6974b 100644 --- a/src/tests/backend/specs/hooks.ts +++ b/src/tests/backend/specs/hooks.js @@ -1,37 +1,15 @@ 'use strict'; -import {strict as assert} from 'assert'; +const assert = require('assert').strict; const hooks = require('../../../static/js/pluginfw/hooks'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); -import sinon from 'sinon'; -import {MapArrayType} from "../../../node/types/MapType"; - - -interface ExtendedConsole extends Console { - warn: { - (message?: any, ...optionalParams: any[]): void; - callCount: number; - getCall: (i: number) => {args: any[]}; - }; - error: { - (message?: any, ...optionalParams: any[]): void; - callCount: number; - getCall: (i: number) => {args: any[]}; - callsFake: (fn: Function) => void; - getCalls: () => {args: any[]}[]; - }; -} - -declare var console: ExtendedConsole; +const sinon = require('sinon'); describe(__filename, function () { - - - const hookName = 'testHook'; const hookFnName = 'testPluginFileName:testHookFunctionName'; let testHooks; // Convenience shorthand for plugins.hooks[hookName]. - let hook: any; // Convenience shorthand for plugins.hooks[hookName][0]. + let hook; // Convenience shorthand for plugins.hooks[hookName][0]. beforeEach(async function () { // Make sure these are not already set so that we don't accidentally step on someone else's @@ -54,12 +32,12 @@ describe(__filename, function () { delete hooks.exportedForTestingOnly.deprecationWarned[hookFnName]; }); - const makeHook = (ret?:any) => ({ + const makeHook = (ret) => ({ hook_name: hookName, // Many tests will likely want to change this. Unfortunately, we can't use a convenience // wrapper like `(...args) => hookFn(..args)` because the hooks look at Function.length and // change behavior depending on the number of parameters. - hook_fn: (hn:Function, ctx:any, cb:Function) => cb(ret), + hook_fn: (hn, ctx, cb) => cb(ret), hook_fn_name: hookFnName, part: {plugin: 'testPluginName'}, }); @@ -68,43 +46,43 @@ describe(__filename, function () { const supportedSyncHookFunctions = [ { name: 'return non-Promise value, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => 'val', + fn: (hn, ctx, cb) => 'val', want: 'val', syncOk: true, }, { name: 'return non-Promise value, without callback parameter', - fn: (hn:Function, ctx:any) => 'val', + fn: (hn, ctx) => 'val', want: 'val', syncOk: true, }, { name: 'return undefined, without callback parameter', - fn: (hn:Function, ctx:any) => {}, + fn: (hn, ctx) => {}, want: undefined, syncOk: true, }, { name: 'pass non-Promise value to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb('val'); }, + fn: (hn, ctx, cb) => { cb('val'); }, want: 'val', syncOk: true, }, { name: 'pass undefined to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb(); }, + fn: (hn, ctx, cb) => { cb(); }, want: undefined, syncOk: true, }, { name: 'return the value returned from the callback', - fn: (hn:Function, ctx:any, cb:Function) => cb('val'), + fn: (hn, ctx, cb) => cb('val'), want: 'val', syncOk: true, }, { name: 'throw', - fn: (hn:Function, ctx:any, cb:Function) => { throw new Error('test exception'); }, + fn: (hn, ctx, cb) => { throw new Error('test exception'); }, wantErr: 'test exception', syncOk: true, }, @@ -115,50 +93,55 @@ describe(__filename, function () { describe('basic behavior', function () { it('passes hook name', async function () { - hook.hook_fn = (hn: string) => { assert.equal(hn, hookName); }; + this.timeout(30); + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; callHookFnSync(hook); }); it('passes context', async function () { + this.timeout(30); for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn: string, ctx:string) => { assert.equal(ctx, val); }; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; callHookFnSync(hook, val); } }); it('returns the value provided to the callback', async function () { + this.timeout(30); for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(ctx); }; + hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; assert.equal(callHookFnSync(hook, val), val); } }); it('returns the value returned by the hook function', async function () { + this.timeout(30); for (const val of ['value', null, undefined]) { // Must not have the cb parameter otherwise returning undefined will error. - hook.hook_fn = (hn: string, ctx: any) => ctx; + hook.hook_fn = (hn, ctx) => ctx; assert.equal(callHookFnSync(hook, val), val); } }); it('does not catch exceptions', async function () { + this.timeout(30); hook.hook_fn = () => { throw new Error('test exception'); }; assert.throws(() => callHookFnSync(hook), {message: 'test exception'}); }); it('callback returns undefined', async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { assert.equal(cb('foo'), undefined); }; + this.timeout(30); + hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; callHookFnSync(hook); }); it('checks for deprecation', async function () { + this.timeout(30); sinon.stub(console, 'warn'); hooks.deprecationNotices[hookName] = 'test deprecation'; callHookFnSync(hook); assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true); - // @ts-ignore assert.equal(console.warn.callCount, 1); - // @ts-ignore assert.match(console.warn.getCall(0).args[0], /test deprecation/); }); }); @@ -166,6 +149,7 @@ describe(__filename, function () { describe('supported hook function styles', function () { for (const tc of supportedSyncHookFunctions) { it(tc.name, async function () { + this.timeout(30); sinon.stub(console, 'warn'); sinon.stub(console, 'error'); hook.hook_fn = tc.fn; @@ -190,7 +174,7 @@ describe(__filename, function () { name: 'never settles -> buggy hook detected', // Note that returning undefined without calling the callback is permitted if the function // has 2 or fewer parameters, so this test function must have 3 parameters. - fn: (hn:Function, ctx:any, cb:Function) => {}, + fn: (hn, ctx, cb) => {}, wantVal: undefined, wantError: /UNSETTLED FUNCTION BUG/, }, @@ -202,7 +186,7 @@ describe(__filename, function () { }, { name: 'passes a Promise to cb -> buggy hook detected', - fn: (hn:Function, ctx:any, cb:Function) => cb(promise2), + fn: (hn, ctx, cb) => cb(promise2), wantVal: promise2, wantError: /PROHIBITED PROMISE BUG/, }, @@ -210,6 +194,7 @@ describe(__filename, function () { for (const tc of testCases) { it(tc.name, async function () { + this.timeout(30); sinon.stub(console, 'error'); hook.hook_fn = tc.fn; assert.equal(callHookFnSync(hook), tc.wantVal); @@ -233,20 +218,20 @@ describe(__filename, function () { const behaviors = [ { name: 'throw', - fn: (cb: Function, err:any, val: string) => { throw err; }, + fn: (cb, err, val) => { throw err; }, rejects: true, }, { name: 'return value', - fn: (cb: Function, err:any, val: string) => val, + fn: (cb, err, val) => val, }, { name: 'immediately call cb(value)', - fn: (cb: Function, err:any, val: string) => cb(val), + fn: (cb, err, val) => cb(val), }, { name: 'defer call to cb(value)', - fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, val); }, + fn: (cb, err, val) => { process.nextTick(cb, val); }, async: true, }, ]; @@ -261,7 +246,8 @@ describe(__filename, function () { if (step1.async && step2.async) continue; it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { + this.timeout(30); + hook.hook_fn = (hn, ctx, cb) => { step1.fn(cb, new Error(ctx.ret1), ctx.ret1); return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); }; @@ -269,7 +255,7 @@ describe(__filename, function () { // Temporarily remove unhandled error listeners so that the errors we expect to see // don't trigger a test failure (or terminate node). const events = ['uncaughtException', 'unhandledRejection']; - const listenerBackups:MapArrayType = {}; + const listenerBackups = {}; for (const event of events) { listenerBackups[event] = process.rawListeners(event); process.removeAllListeners(event); @@ -280,18 +266,17 @@ describe(__filename, function () { // a throw (in which case the double settle is deferred so that the caller sees the // original error). const wantAsyncErr = step1.async || step2.async || step2.rejects; - let tempListener:Function; - let asyncErr:Error|undefined; + let tempListener; + let asyncErr; try { - const seenErrPromise = new Promise((resolve) => { - tempListener = (err:any) => { + const seenErrPromise = new Promise((resolve) => { + tempListener = (err) => { assert.equal(asyncErr, undefined); asyncErr = err; resolve(); }; if (!wantAsyncErr) resolve(); }); - // @ts-ignore events.forEach((event) => process.on(event, tempListener)); const call = () => callHookFnSync(hook, {ret1: 'val1', ret2: 'val2'}); if (step2.rejects) { @@ -305,7 +290,6 @@ describe(__filename, function () { } finally { // Restore the original listeners. for (const event of events) { - // @ts-ignore process.off(event, tempListener); for (const listener of listenerBackups[event]) { process.on(event, listener); @@ -326,8 +310,9 @@ describe(__filename, function () { if (step1.rejects !== step2.rejects) continue; it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { + this.timeout(30); const err = new Error('val'); - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { + hook.hook_fn = (hn, ctx, cb) => { step1.fn(cb, err, 'val'); return step2.fn(cb, err, 'val'); }; @@ -351,63 +336,74 @@ describe(__filename, function () { describe('hooks.callAll', function () { describe('basic behavior', function () { it('calls all in order', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(1), makeHook(2), makeHook(3)); assert.deepEqual(hooks.callAll(hookName), [1, 2, 3]); }); it('passes hook name', async function () { - hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); }; + this.timeout(30); + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; hooks.callAll(hookName); }); it('undefined context -> {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hooks.callAll(hookName); }); it('null context -> {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hooks.callAll(hookName, null); }); it('context unmodified', async function () { + this.timeout(30); const wantContext = {}; - hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; hooks.callAll(hookName, wantContext); }); }); describe('result processing', function () { it('no registered hooks (undefined) -> []', async function () { + this.timeout(30); delete plugins.hooks.testHook; assert.deepEqual(hooks.callAll(hookName), []); }); it('no registered hooks (empty list) -> []', async function () { + this.timeout(30); testHooks.length = 0; assert.deepEqual(hooks.callAll(hookName), []); }); it('flattens one level', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); assert.deepEqual(hooks.callAll(hookName), [1, 2, [3]]); }); it('filters out undefined', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook([2]), makeHook([[3]])); assert.deepEqual(hooks.callAll(hookName), [2, [3]]); }); it('preserves null', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]])); assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]); }); it('all undefined -> []', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook()); assert.deepEqual(hooks.callAll(hookName), []); @@ -417,38 +413,45 @@ describe(__filename, function () { describe('hooks.callFirst', function () { it('no registered hooks (undefined) -> []', async function () { + this.timeout(30); delete plugins.hooks.testHook; assert.deepEqual(hooks.callFirst(hookName), []); }); it('no registered hooks (empty list) -> []', async function () { + this.timeout(30); testHooks.length = 0; assert.deepEqual(hooks.callFirst(hookName), []); }); it('passes hook name => {}', async function () { - hook.hook_fn = (hn: string) => { assert.equal(hn, hookName); }; + this.timeout(30); + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; hooks.callFirst(hookName); }); it('undefined context => {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hooks.callFirst(hookName); }); it('null context => {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hooks.callFirst(hookName, null); }); it('context unmodified', async function () { + this.timeout(30); const wantContext = {}; - hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; hooks.callFirst(hookName, wantContext); }); it('predicate never satisfied -> calls all in order', async function () { - const gotCalls:MapArrayType = []; + this.timeout(30); + const gotCalls = []; testHooks.length = 0; for (let i = 0; i < 3; i++) { const hook = makeHook(); @@ -460,30 +463,35 @@ describe(__filename, function () { }); it('stops when predicate is satisfied', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); assert.deepEqual(hooks.callFirst(hookName), ['val1']); }); it('skips values that do not satisfy predicate (undefined)', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook('val1')); assert.deepEqual(hooks.callFirst(hookName), ['val1']); }); it('skips values that do not satisfy predicate (empty list)', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook([]), makeHook('val1')); assert.deepEqual(hooks.callFirst(hookName), ['val1']); }); it('null satisifes the predicate', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(null), makeHook('val1')); assert.deepEqual(hooks.callFirst(hookName), [null]); }); it('non-empty arrays are returned unmodified', async function () { + this.timeout(30); const want = ['val1']; testHooks.length = 0; testHooks.push(makeHook(want), makeHook(['val2'])); @@ -491,8 +499,9 @@ describe(__filename, function () { }); it('value can be passed via callback', async function () { + this.timeout(30); const want = {}; - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(want); }; + hook.hook_fn = (hn, ctx, cb) => { cb(want); }; const got = hooks.callFirst(hookName); assert.deepEqual(got, [want]); assert.equal(got[0], want); // Note: *NOT* deepEqual! @@ -504,55 +513,64 @@ describe(__filename, function () { describe('basic behavior', function () { it('passes hook name', async function () { - hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); }; + this.timeout(30); + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; await callHookFnAsync(hook); }); it('passes context', async function () { + this.timeout(30); for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, val); }; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; await callHookFnAsync(hook, val); } }); it('returns the value provided to the callback', async function () { + this.timeout(30); for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(ctx); }; + hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; assert.equal(await callHookFnAsync(hook, val), val); assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); } }); it('returns the value returned by the hook function', async function () { + this.timeout(30); for (const val of ['value', null, undefined]) { // Must not have the cb parameter otherwise returning undefined will never resolve. - hook.hook_fn = (hn: string, ctx: any) => ctx; + hook.hook_fn = (hn, ctx) => ctx; assert.equal(await callHookFnAsync(hook, val), val); assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); } }); it('rejects if it throws an exception', async function () { + this.timeout(30); hook.hook_fn = () => { throw new Error('test exception'); }; await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); }); it('rejects if rejected Promise passed to callback', async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => cb(Promise.reject(new Error('test exception'))); + this.timeout(30); + hook.hook_fn = (hn, ctx, cb) => cb(Promise.reject(new Error('test exception'))); await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); }); it('rejects if rejected Promise returned', async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => Promise.reject(new Error('test exception')); + this.timeout(30); + hook.hook_fn = (hn, ctx, cb) => Promise.reject(new Error('test exception')); await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); }); it('callback returns undefined', async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { assert.equal(cb('foo'), undefined); }; + this.timeout(30); + hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; await callHookFnAsync(hook); }); it('checks for deprecation', async function () { + this.timeout(30); sinon.stub(console, 'warn'); hooks.deprecationNotices[hookName] = 'test deprecation'; await callHookFnAsync(hook); @@ -563,79 +581,78 @@ describe(__filename, function () { }); describe('supported hook function styles', function () { - // @ts-ignore const supportedHookFunctions = supportedSyncHookFunctions.concat([ { name: 'legacy async cb', - fn: (hn:Function, ctx:any, cb:Function) => { process.nextTick(cb, 'val'); }, + fn: (hn, ctx, cb) => { process.nextTick(cb, 'val'); }, want: 'val', }, // Already resolved Promises: { name: 'return resolved Promise, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => Promise.resolve('val'), + fn: (hn, ctx, cb) => Promise.resolve('val'), want: 'val', }, { name: 'return resolved Promise, without callback parameter', - fn: (hn: string, ctx: any) => Promise.resolve('val'), + fn: (hn, ctx) => Promise.resolve('val'), want: 'val', }, { name: 'pass resolved Promise to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb(Promise.resolve('val')); }, + fn: (hn, ctx, cb) => { cb(Promise.resolve('val')); }, want: 'val', }, // Not yet resolved Promises: { name: 'return unresolved Promise, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => new Promise((resolve) => process.nextTick(resolve, 'val')), + fn: (hn, ctx, cb) => new Promise((resolve) => process.nextTick(resolve, 'val')), want: 'val', }, { name: 'return unresolved Promise, without callback parameter', - fn: (hn: string, ctx: any) => new Promise((resolve) => process.nextTick(resolve, 'val')), + fn: (hn, ctx) => new Promise((resolve) => process.nextTick(resolve, 'val')), want: 'val', }, { name: 'pass unresolved Promise to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb(new Promise((resolve) => process.nextTick(resolve, 'val'))); }, + fn: (hn, ctx, cb) => { cb(new Promise((resolve) => process.nextTick(resolve, 'val'))); }, want: 'val', }, // Already rejected Promises: { name: 'return rejected Promise, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => Promise.reject(new Error('test rejection')), + fn: (hn, ctx, cb) => Promise.reject(new Error('test rejection')), wantErr: 'test rejection', }, { name: 'return rejected Promise, without callback parameter', - fn: (hn: string, ctx: any) => Promise.reject(new Error('test rejection')), + fn: (hn, ctx) => Promise.reject(new Error('test rejection')), wantErr: 'test rejection', }, { name: 'pass rejected Promise to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb(Promise.reject(new Error('test rejection'))); }, + fn: (hn, ctx, cb) => { cb(Promise.reject(new Error('test rejection'))); }, wantErr: 'test rejection', }, // Not yet rejected Promises: { name: 'return unrejected Promise, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => new Promise((resolve, reject) => { + fn: (hn, ctx, cb) => new Promise((resolve, reject) => { process.nextTick(reject, new Error('test rejection')); }), wantErr: 'test rejection', }, { name: 'return unrejected Promise, without callback parameter', - fn: (hn: string, ctx: any) => new Promise((resolve, reject) => { + fn: (hn, ctx) => new Promise((resolve, reject) => { process.nextTick(reject, new Error('test rejection')); }), wantErr: 'test rejection', }, { name: 'pass unrejected Promise to callback', - fn: (hn:Function, ctx:any, cb:Function) => { + fn: (hn, ctx, cb) => { cb(new Promise((resolve, reject) => { process.nextTick(reject, new Error('test rejection')); })); @@ -646,6 +663,7 @@ describe(__filename, function () { for (const tc of supportedSyncHookFunctions.concat(supportedHookFunctions)) { it(tc.name, async function () { + this.timeout(30); sinon.stub(console, 'warn'); sinon.stub(console, 'error'); hook.hook_fn = tc.fn; @@ -681,13 +699,13 @@ describe(__filename, function () { const behaviors = [ { name: 'throw', - fn: (cb: Function, err:any, val: string) => { throw err; }, + fn: (cb, err, val) => { throw err; }, rejects: true, when: 0, }, { name: 'return value', - fn: (cb: Function, err:any, val: string) => val, + fn: (cb, err, val) => val, // This behavior has a later relative settle time vs. the 'throw' behavior because 'throw' // immediately settles the hook function, whereas the 'return value' case is settled by a // .then() function attached to a Promise. EcmaScript guarantees that a .then() function @@ -697,14 +715,14 @@ describe(__filename, function () { }, { name: 'immediately call cb(value)', - fn: (cb: Function, err:any, val: string) => cb(val), + fn: (cb, err, val) => cb(val), // This behavior has the same relative time as the 'return value' case because it too is // settled by a .then() function attached to a Promise. when: 1, }, { name: 'return resolvedPromise', - fn: (cb: Function, err:any, val: string) => Promise.resolve(val), + fn: (cb, err, val) => Promise.resolve(val), // This behavior has the same relative time as the 'return value' case because the return // value is wrapped in a Promise via Promise.resolve(). The EcmaScript standard guarantees // that Promise.resolve(Promise.resolve(value)) is equivalent to Promise.resolve(value), @@ -714,62 +732,62 @@ describe(__filename, function () { }, { name: 'immediately call cb(resolvedPromise)', - fn: (cb: Function, err:any, val: string) => cb(Promise.resolve(val)), + fn: (cb, err, val) => cb(Promise.resolve(val)), when: 1, }, { name: 'return rejectedPromise', - fn: (cb: Function, err:any, val: string) => Promise.reject(err), + fn: (cb, err, val) => Promise.reject(err), rejects: true, when: 1, }, { name: 'immediately call cb(rejectedPromise)', - fn: (cb: Function, err:any, val: string) => cb(Promise.reject(err)), + fn: (cb, err, val) => cb(Promise.reject(err)), rejects: true, when: 1, }, { name: 'return unresolvedPromise', - fn: (cb: Function, err:any, val: string) => new Promise((resolve) => process.nextTick(resolve, val)), + fn: (cb, err, val) => new Promise((resolve) => process.nextTick(resolve, val)), when: 2, }, { name: 'immediately call cb(unresolvedPromise)', - fn: (cb: Function, err:any, val: string) => cb(new Promise((resolve) => process.nextTick(resolve, val))), + fn: (cb, err, val) => cb(new Promise((resolve) => process.nextTick(resolve, val))), when: 2, }, { name: 'return unrejectedPromise', - fn: (cb: Function, err:any, val: string) => new Promise((resolve, reject) => process.nextTick(reject, err)), + fn: (cb, err, val) => new Promise((resolve, reject) => process.nextTick(reject, err)), rejects: true, when: 2, }, { name: 'immediately call cb(unrejectedPromise)', - fn: (cb: Function, err:any, val: string) => cb(new Promise((resolve, reject) => process.nextTick(reject, err))), + fn: (cb, err, val) => cb(new Promise((resolve, reject) => process.nextTick(reject, err))), rejects: true, when: 2, }, { name: 'defer call to cb(value)', - fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, val); }, + fn: (cb, err, val) => { process.nextTick(cb, val); }, when: 2, }, { name: 'defer call to cb(resolvedPromise)', - fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, Promise.resolve(val)); }, + fn: (cb, err, val) => { process.nextTick(cb, Promise.resolve(val)); }, when: 2, }, { name: 'defer call to cb(rejectedPromise)', - fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, Promise.reject(err)); }, + fn: (cb, err, val) => { process.nextTick(cb, Promise.reject(err)); }, rejects: true, when: 2, }, { name: 'defer call to cb(unresolvedPromise)', - fn: (cb: Function, err:any, val: string) => { + fn: (cb, err, val) => { process.nextTick(() => { cb(new Promise((resolve) => process.nextTick(resolve, val))); }); @@ -778,7 +796,7 @@ describe(__filename, function () { }, { name: 'defer call cb(unrejectedPromise)', - fn: (cb: Function, err:any, val: string) => { + fn: (cb, err, val) => { process.nextTick(() => { cb(new Promise((resolve, reject) => process.nextTick(reject, err))); }); @@ -793,7 +811,8 @@ describe(__filename, function () { if (step1.name.startsWith('return ') || step1.name === 'throw') continue; for (const step2 of behaviors) { it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { + this.timeout(30); + hook.hook_fn = (hn, ctx, cb) => { step1.fn(cb, new Error(ctx.ret1), ctx.ret1); return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); }; @@ -805,16 +824,16 @@ describe(__filename, function () { process.removeAllListeners(event); let tempListener; - let asyncErr: Error; + let asyncErr; try { - const seenErrPromise = new Promise((resolve) => { - tempListener = (err:any) => { + const seenErrPromise = new Promise((resolve) => { + tempListener = (err) => { assert.equal(asyncErr, undefined); asyncErr = err; resolve(); }; }); - process.on(event, tempListener!); + process.on(event, tempListener); const step1Wins = step1.when <= step2.when; const winningStep = step1Wins ? step1 : step2; const winningVal = step1Wins ? 'val1' : 'val2'; @@ -827,16 +846,15 @@ describe(__filename, function () { await seenErrPromise; } finally { // Restore the original listeners. - process.off(event, tempListener!); + process.off(event, tempListener); for (const listener of listenersBackup) { - process.on(event, listener as any); + process.on(event, listener); } } assert.equal(console.error.callCount, 1, `Got errors:\n${ console.error.getCalls().map((call) => call.args[0]).join('\n')}`); assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); - // @ts-ignore assert(asyncErr instanceof Error); assert.match(asyncErr.message, /DOUBLE SETTLE BUG/); }); @@ -847,8 +865,9 @@ describe(__filename, function () { if (step1.rejects !== step2.rejects) continue; it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { + this.timeout(30); const err = new Error('val'); - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { + hook.hook_fn = (hn, ctx, cb) => { step1.fn(cb, err, 'val'); return step2.fn(cb, err, 'val'); }; @@ -872,21 +891,15 @@ describe(__filename, function () { describe('hooks.aCallAll', function () { describe('basic behavior', function () { it('calls all asynchronously, returns values in order', async function () { + this.timeout(30); testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it. let nextIndex = 0; - const hookPromises: { - promise?: Promise, - resolve?: Function, - } [] - = []; - const hookStarted: boolean[] = []; - const hookFinished :boolean[]= []; + const hookPromises = []; + const hookStarted = []; + const hookFinished = []; const makeHook = () => { const i = nextIndex++; - const entry:{ - promise?: Promise, - resolve?: Function, - } = {}; + const entry = {}; hookStarted[i] = false; hookFinished[i] = false; hookPromises[i] = entry; @@ -905,97 +918,112 @@ describe(__filename, function () { const p = hooks.aCallAll(hookName); assert.deepEqual(hookStarted, [true, true]); assert.deepEqual(hookFinished, [false, false]); - hookPromises[1].resolve!(); + hookPromises[1].resolve(); await hookPromises[1].promise; assert.deepEqual(hookFinished, [false, true]); - hookPromises[0].resolve!(); + hookPromises[0].resolve(); assert.deepEqual(await p, [0, 1]); }); it('passes hook name', async function () { - hook.hook_fn = async (hn:string) => { assert.equal(hn, hookName); }; + this.timeout(30); + hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; await hooks.aCallAll(hookName); }); it('undefined context -> {}', async function () { - hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; await hooks.aCallAll(hookName); }); it('null context -> {}', async function () { - hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; await hooks.aCallAll(hookName, null); }); it('context unmodified', async function () { + this.timeout(30); const wantContext = {}; - hook.hook_fn = async (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; await hooks.aCallAll(hookName, wantContext); }); }); describe('aCallAll callback', function () { it('exception in callback rejects', async function () { + this.timeout(30); const p = hooks.aCallAll(hookName, {}, () => { throw new Error('test exception'); }); await assert.rejects(p, {message: 'test exception'}); }); it('propagates error on exception', async function () { + this.timeout(30); hook.hook_fn = () => { throw new Error('test exception'); }; - await hooks.aCallAll(hookName, {}, (err:any) => { + await hooks.aCallAll(hookName, {}, (err) => { assert(err instanceof Error); assert.equal(err.message, 'test exception'); }); }); it('propagages null error on success', async function () { - await hooks.aCallAll(hookName, {}, (err:any) => { + this.timeout(30); + await hooks.aCallAll(hookName, {}, (err) => { assert(err == null, `got non-null error: ${err}`); }); }); it('propagages results on success', async function () { + this.timeout(30); hook.hook_fn = () => 'val'; - await hooks.aCallAll(hookName, {}, (err:any, results:any) => { + await hooks.aCallAll(hookName, {}, (err, results) => { assert.deepEqual(results, ['val']); }); }); it('returns callback return value', async function () { + this.timeout(30); assert.equal(await hooks.aCallAll(hookName, {}, () => 'val'), 'val'); }); }); describe('result processing', function () { it('no registered hooks (undefined) -> []', async function () { + this.timeout(30); delete plugins.hooks[hookName]; assert.deepEqual(await hooks.aCallAll(hookName), []); }); it('no registered hooks (empty list) -> []', async function () { + this.timeout(30); testHooks.length = 0; assert.deepEqual(await hooks.aCallAll(hookName), []); }); it('flattens one level', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); assert.deepEqual(await hooks.aCallAll(hookName), [1, 2, [3]]); }); it('filters out undefined', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); assert.deepEqual(await hooks.aCallAll(hookName), [2, [3]]); }); it('preserves null', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); assert.deepEqual(await hooks.aCallAll(hookName), [null, 2, null]); }); it('all undefined -> []', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook(Promise.resolve())); assert.deepEqual(await hooks.aCallAll(hookName), []); @@ -1006,7 +1034,8 @@ describe(__filename, function () { describe('hooks.callAllSerial', function () { describe('basic behavior', function () { it('calls all asynchronously, serially, in order', async function () { - const gotCalls:number[] = []; + this.timeout(30); + const gotCalls = []; testHooks.length = 0; for (let i = 0; i < 3; i++) { const hook = makeHook(); @@ -1028,57 +1057,67 @@ describe(__filename, function () { }); it('passes hook name', async function () { - hook.hook_fn = async (hn:string) => { assert.equal(hn, hookName); }; + this.timeout(30); + hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; await hooks.callAllSerial(hookName); }); it('undefined context -> {}', async function () { - hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; await hooks.callAllSerial(hookName); }); it('null context -> {}', async function () { - hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; await hooks.callAllSerial(hookName, null); }); it('context unmodified', async function () { + this.timeout(30); const wantContext = {}; - hook.hook_fn = async (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; await hooks.callAllSerial(hookName, wantContext); }); }); describe('result processing', function () { it('no registered hooks (undefined) -> []', async function () { + this.timeout(30); delete plugins.hooks[hookName]; assert.deepEqual(await hooks.callAllSerial(hookName), []); }); it('no registered hooks (empty list) -> []', async function () { + this.timeout(30); testHooks.length = 0; assert.deepEqual(await hooks.callAllSerial(hookName), []); }); it('flattens one level', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]); }); it('filters out undefined', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]); }); it('preserves null', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]); }); it('all undefined -> []', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook(Promise.resolve())); assert.deepEqual(await hooks.callAllSerial(hookName), []); @@ -1088,38 +1127,45 @@ describe(__filename, function () { describe('hooks.aCallFirst', function () { it('no registered hooks (undefined) -> []', async function () { + this.timeout(30); delete plugins.hooks.testHook; assert.deepEqual(await hooks.aCallFirst(hookName), []); }); it('no registered hooks (empty list) -> []', async function () { + this.timeout(30); testHooks.length = 0; assert.deepEqual(await hooks.aCallFirst(hookName), []); }); it('passes hook name => {}', async function () { - hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); }; + this.timeout(30); + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; await hooks.aCallFirst(hookName); }); it('undefined context => {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; await hooks.aCallFirst(hookName); }); it('null context => {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; + this.timeout(30); + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; await hooks.aCallFirst(hookName, null); }); it('context unmodified', async function () { + this.timeout(30); const wantContext = {}; - hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; await hooks.aCallFirst(hookName, wantContext); }); it('default predicate: predicate never satisfied -> calls all in order', async function () { - const gotCalls:number[] = []; + this.timeout(30); + const gotCalls = []; testHooks.length = 0; for (let i = 0; i < 3; i++) { const hook = makeHook(); @@ -1131,7 +1177,8 @@ describe(__filename, function () { }); it('calls hook functions serially', async function () { - const gotCalls: number[] = []; + this.timeout(30); + const gotCalls = []; testHooks.length = 0; for (let i = 0; i < 3; i++) { const hook = makeHook(); @@ -1139,7 +1186,7 @@ describe(__filename, function () { gotCalls.push(i); // Check gotCalls asynchronously to ensure that the next hook function does not start // executing before this hook function has resolved. - return await new Promise((resolve) => { + return await new Promise((resolve) => { setImmediate(() => { assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); resolve(); @@ -1153,42 +1200,48 @@ describe(__filename, function () { }); it('default predicate: stops when satisfied', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); }); it('default predicate: skips values that do not satisfy (undefined)', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(), makeHook('val1')); assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); }); it('default predicate: skips values that do not satisfy (empty list)', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook([]), makeHook('val1')); assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); }); it('default predicate: null satisifes', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(null), makeHook('val1')); assert.deepEqual(await hooks.aCallFirst(hookName), [null]); }); it('custom predicate: called for each hook function', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(0), makeHook(1), makeHook(2)); let got = 0; - await hooks.aCallFirst(hookName, null, null, (val:string) => { ++got; return false; }); + await hooks.aCallFirst(hookName, null, null, (val) => { ++got; return false; }); assert.equal(got, 3); }); it('custom predicate: boolean false/true continues/stops iteration', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(1), makeHook(2), makeHook(3)); let nCall = 0; - const predicate = (val: number[]) => { + const predicate = (val) => { assert.deepEqual(val, [++nCall]); return nCall === 2; }; @@ -1197,10 +1250,11 @@ describe(__filename, function () { }); it('custom predicate: non-boolean falsy/truthy continues/stops iteration', async function () { + this.timeout(30); testHooks.length = 0; testHooks.push(makeHook(1), makeHook(2), makeHook(3)); let nCall = 0; - const predicate = (val: number[]) => { + const predicate = (val) => { assert.deepEqual(val, [++nCall]); return nCall === 2 ? {} : null; }; @@ -1209,24 +1263,28 @@ describe(__filename, function () { }); it('custom predicate: array value passed unmodified to predicate', async function () { + this.timeout(30); const want = [0]; hook.hook_fn = () => want; - const predicate = (got: []) => { assert.equal(got, want); }; // Note: *NOT* deepEqual! + const predicate = (got) => { assert.equal(got, want); }; // Note: *NOT* deepEqual! await hooks.aCallFirst(hookName, null, null, predicate); }); it('custom predicate: normalized value passed to predicate (undefined)', async function () { - const predicate = (got: []) => { assert.deepEqual(got, []); }; + this.timeout(30); + const predicate = (got) => { assert.deepEqual(got, []); }; await hooks.aCallFirst(hookName, null, null, predicate); }); it('custom predicate: normalized value passed to predicate (null)', async function () { + this.timeout(30); hook.hook_fn = () => null; - const predicate = (got: []) => { assert.deepEqual(got, [null]); }; + const predicate = (got) => { assert.deepEqual(got, [null]); }; await hooks.aCallFirst(hookName, null, null, predicate); }); it('non-empty arrays are returned unmodified', async function () { + this.timeout(30); const want = ['val1']; testHooks.length = 0; testHooks.push(makeHook(want), makeHook(['val2'])); @@ -1234,8 +1292,9 @@ describe(__filename, function () { }); it('value can be passed via callback', async function () { + this.timeout(30); const want = {}; - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(want); }; + hook.hook_fn = (hn, ctx, cb) => { cb(want); }; const got = await hooks.aCallFirst(hookName); assert.deepEqual(got, [want]); assert.equal(got[0], want); // Note: *NOT* deepEqual! diff --git a/src/tests/backend/specs/lowerCasePadIds.ts b/src/tests/backend/specs/lowerCasePadIds.ts deleted file mode 100644 index c85d16c3f..000000000 --- a/src/tests/backend/specs/lowerCasePadIds.ts +++ /dev/null @@ -1,90 +0,0 @@ -'use strict'; - -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const settings = require('../../../node/utils/Settings'); - -describe(__filename, function () { - let agent:any; - const cleanUpPads = async () => { - const {padIDs} = await padManager.listAllPads(); - await Promise.all(padIDs.map(async (padId: string) => { - if (await padManager.doesPadExist(padId)) { - const pad = await padManager.getPad(padId); - await pad.remove(); - } - })); - }; - let backup:any; - - before(async function () { - backup = settings.lowerCasePadIds; - agent = await common.init(); - }); - beforeEach(async function () { - await cleanUpPads(); - }); - afterEach(async function () { - await cleanUpPads(); - }); - after(async function () { - settings.lowerCasePadIds = backup; - }); - - describe('not activated', function () { - beforeEach(async function () { - settings.lowerCasePadIds = false; - }); - - - it('do nothing', async function () { - await agent.get('/p/UPPERCASEpad') - .expect(200); - }); - }); - - describe('activated', function () { - beforeEach(async function () { - settings.lowerCasePadIds = true; - }); - it('lowercase pad ids', async function () { - await agent.get('/p/UPPERCASEpad') - .expect(302) - .expect('location', 'uppercasepad'); - }); - - it('keeps old pads accessible', async function () { - Object.assign(settings, { - lowerCasePadIds: false, - }); - await padManager.getPad('ALREADYexistingPad', 'oldpad'); - await padManager.getPad('alreadyexistingpad', 'newpad'); - Object.assign(settings, { - lowerCasePadIds: true, - }); - - const oldPad = await agent.get('/p/ALREADYexistingPad').expect(200); - const oldPadSocket = await common.connect(oldPad); - const oldPadHandshake = await common.handshake(oldPadSocket, 'ALREADYexistingPad'); - assert.equal(oldPadHandshake.data.padId, 'ALREADYexistingPad'); - assert.equal(oldPadHandshake.data.collab_client_vars.initialAttributedText.text, 'oldpad\n'); - - const newPad = await agent.get('/p/alreadyexistingpad').expect(200); - const newPadSocket = await common.connect(newPad); - const newPadHandshake = await common.handshake(newPadSocket, 'alreadyexistingpad'); - assert.equal(newPadHandshake.data.padId, 'alreadyexistingpad'); - assert.equal(newPadHandshake.data.collab_client_vars.initialAttributedText.text, 'newpad\n'); - }); - - it('disallow creation of different case pad-name via socket connection', async function () { - await padManager.getPad('maliciousattempt', 'attempt'); - - const newPad = await agent.get('/p/maliciousattempt').expect(200); - const newPadSocket = await common.connect(newPad); - const newPadHandshake = await common.handshake(newPadSocket, 'MaliciousAttempt'); - - assert.equal(newPadHandshake.data.collab_client_vars.initialAttributedText.text, 'attempt\n'); - }); - }); -}); diff --git a/src/tests/backend/specs/messages.ts b/src/tests/backend/specs/messages.ts deleted file mode 100644 index 9d91b2342..000000000 --- a/src/tests/backend/specs/messages.ts +++ /dev/null @@ -1,258 +0,0 @@ -'use strict'; - -import {PadType} from "../../../node/types/PadType"; -import {MapArrayType} from "../../../node/types/MapType"; - -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const readOnlyManager = require('../../../node/db/ReadOnlyManager'); - -describe(__filename, function () { - let agent:any; - let pad:PadType|null; - let padId: string; - let roPadId: string; - let rev: number; - let socket: any; - let roSocket: any; - const backups:MapArrayType = {}; - - before(async function () { - agent = await common.init(); - }); - - beforeEach(async function () { - backups.hooks = {handleMessageSecurity: plugins.hooks.handleMessageSecurity}; - plugins.hooks.handleMessageSecurity = []; - padId = common.randomString(); - assert(!await padManager.doesPadExist(padId)); - pad = await padManager.getPad(padId, 'dummy text\n'); - await pad!.setText('\n'); // Make sure the pad is created. - assert.equal(pad!.text(), '\n'); - let res = await agent.get(`/p/${padId}`).expect(200); - socket = await common.connect(res); - const {type, data: clientVars} = await common.handshake(socket, padId); - assert.equal(type, 'CLIENT_VARS'); - rev = clientVars.collab_client_vars.rev; - - roPadId = await readOnlyManager.getReadOnlyId(padId); - res = await agent.get(`/p/${roPadId}`).expect(200); - roSocket = await common.connect(res); - await common.handshake(roSocket, roPadId); - await new Promise(resolve => setTimeout(resolve, 1000)); - }); - - afterEach(async function () { - Object.assign(plugins.hooks, backups.hooks); - if (socket != null) socket.close(); - socket = null; - if (roSocket != null) roSocket.close(); - roSocket = null; - if (pad != null) await pad.remove(); - pad = null; - }); - - describe('CHANGESET_REQ', function () { - it('users are unable to read changesets from other pads', async function () { - const otherPadId = `${padId}other`; - assert(!await padManager.doesPadExist(otherPadId)); - const otherPad = await padManager.getPad(otherPadId, 'other text\n'); - try { - await otherPad.setText('other text\n'); - const resP = common.waitForSocketEvent(roSocket, 'message'); - await common.sendMessage(roSocket, { - component: 'pad', - padId: otherPadId, // The server should ignore this. - type: 'CHANGESET_REQ', - data: { - granularity: 1, - start: 0, - requestID: 'requestId', - }, - }); - const res = await resP; - assert.equal(res.type, 'CHANGESET_REQ'); - assert.equal(res.data.requestID, 'requestId'); - // Should match padId's text, not otherPadId's text. - assert.match(res.data.forwardsChangesets[0], /^[^$]*\$dummy text\n/); - } finally { - await otherPad.remove(); - } - }); - - it('CHANGESET_REQ: verify revNum is a number (regression)', async function () { - const otherPadId = `${padId}other`; - assert(!await padManager.doesPadExist(otherPadId)); - const otherPad = await padManager.getPad(otherPadId, 'other text\n'); - let errorCatched = 0; - try { - await otherPad.setText('other text\n'); - await common.sendMessage(roSocket, { - component: 'pad', - padId: otherPadId, // The server should ignore this. - type: 'CHANGESET_REQ', - data: { - granularity: 1, - start: 'test123', - requestID: 'requestId', - }, - }); - assert.equal('This code should never run', 1); - } - catch(e:any) { - assert.match(e.message, /rev is not a number/); - errorCatched = 1; - } - finally { - await otherPad.remove(); - assert.equal(errorCatched, 1); - } - }); - - it('CHANGESET_REQ: revNum is converted to number if possible (regression)', async function () { - const otherPadId = `${padId}other`; - assert(!await padManager.doesPadExist(otherPadId)); - const otherPad = await padManager.getPad(otherPadId, 'other text\n'); - try { - await otherPad.setText('other text\n'); - const resP = common.waitForSocketEvent(roSocket, 'message'); - await common.sendMessage(roSocket, { - component: 'pad', - padId: otherPadId, // The server should ignore this. - type: 'CHANGESET_REQ', - data: { - granularity: 1, - start: '1test123', - requestID: 'requestId', - }, - }); - const res = await resP; - assert.equal(res.type, 'CHANGESET_REQ'); - assert.equal(res.data.requestID, 'requestId'); - assert.equal(res.data.start, 1); - } - finally { - await otherPad.remove(); - } - }); - - it('CHANGESET_REQ: revNum 2 is converted to head rev 1 (regression)', async function () { - const otherPadId = `${padId}other`; - assert(!await padManager.doesPadExist(otherPadId)); - const otherPad = await padManager.getPad(otherPadId, 'other text\n'); - try { - await otherPad.setText('other text\n'); - const resP = common.waitForSocketEvent(roSocket, 'message'); - await common.sendMessage(roSocket, { - component: 'pad', - padId: otherPadId, // The server should ignore this. - type: 'CHANGESET_REQ', - data: { - granularity: 1, - start: '2', - requestID: 'requestId', - }, - }); - const res = await resP; - assert.equal(res.type, 'CHANGESET_REQ'); - assert.equal(res.data.requestID, 'requestId'); - assert.equal(res.data.start, 1); - } - finally { - await otherPad.remove(); - } - }); - }); - - describe('USER_CHANGES', function () { - const sendUserChanges = - async (socket:any, cs:any) => await common.sendUserChanges(socket, {baseRev: rev, changeset: cs}); - const assertAccepted = async (socket:any, wantRev: number) => { - await common.waitForAcceptCommit(socket, wantRev); - rev = wantRev; - }; - const assertRejected = async (socket:any) => { - const msg = await common.waitForSocketEvent(socket, 'message'); - assert.deepEqual(msg, {disconnect: 'badChangeset'}); - }; - - it('changes are applied', async function () { - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, 'Z:1>5+5$hello'), - ]); - assert.equal(pad!.text(), 'hello\n'); - }); - - it('bad changeset is rejected', async function () { - await Promise.all([ - assertRejected(socket), - sendUserChanges(socket, 'this is not a valid changeset'), - ]); - }); - - it('retransmission is accepted, has no effect', async function () { - const cs = 'Z:1>5+5$hello'; - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, cs), - ]); - --rev; - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, cs), - ]); - assert.equal(pad!.text(), 'hello\n'); - }); - - it('identity changeset is accepted, has no effect', async function () { - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, 'Z:1>5+5$hello'), - ]); - await Promise.all([ - assertAccepted(socket, rev), - sendUserChanges(socket, 'Z:6>0$'), - ]); - assert.equal(pad!.text(), 'hello\n'); - }); - - it('non-identity changeset with no net change is accepted, has no effect', async function () { - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, 'Z:1>5+5$hello'), - ]); - await Promise.all([ - assertAccepted(socket, rev), - sendUserChanges(socket, 'Z:6>0-5+5$hello'), - ]); - assert.equal(pad!.text(), 'hello\n'); - }); - - it('handleMessageSecurity can grant one-time write access', async function () { - const cs = 'Z:1>5+5$hello'; - const errRegEx = /write attempt on read-only pad/; - // First try to send a change and verify that it was dropped. - await assert.rejects(sendUserChanges(roSocket, cs), errRegEx); - // sendUserChanges() waits for message ack, so if the message was accepted then head should - // have already incremented by the time we get here. - assert.equal(pad!.head, rev); // Not incremented. - - // Now allow the change. - plugins.hooks.handleMessageSecurity.push({hook_fn: () => 'permitOnce'}); - await Promise.all([ - assertAccepted(roSocket, rev + 1), - sendUserChanges(roSocket, cs), - ]); - assert.equal(pad!.text(), 'hello\n'); - - // The next change should be dropped. - plugins.hooks.handleMessageSecurity = []; - await assert.rejects(sendUserChanges(roSocket, 'Z:6>6=5+6$ world'), errRegEx); - assert.equal(pad!.head, rev); // Not incremented. - assert.equal(pad!.text(), 'hello\n'); - }); - }); -}); diff --git a/src/tests/backend/specs/pads-with-spaces.ts b/src/tests/backend/specs/pads-with-spaces.ts deleted file mode 100644 index cfadca1b9..000000000 --- a/src/tests/backend/specs/pads-with-spaces.ts +++ /dev/null @@ -1,23 +0,0 @@ -'use strict'; - -const common = require('../common'); - -let agent:any; - -describe(__filename, function () { - before(async function () { - agent = await common.init(); - }); - - it('supports pads with spaces, regression test for #4883', async function () { - await agent.get('/p/pads with spaces') - .expect(302) - .expect('location', 'pads_with_spaces'); - }); - - it('supports pads with spaces and query, regression test for #4883', async function () { - await agent.get('/p/pads with spaces?showChat=true&noColors=false') - .expect(302) - .expect('location', 'pads_with_spaces?showChat=true&noColors=false'); - }); -}); diff --git a/src/tests/backend-new/specs/promises.ts b/src/tests/backend/specs/promises.js similarity index 54% rename from src/tests/backend-new/specs/promises.ts rename to src/tests/backend/specs/promises.js index 2ce6aed27..ad0c1ad92 100644 --- a/src/tests/backend-new/specs/promises.ts +++ b/src/tests/backend/specs/promises.js @@ -1,44 +1,37 @@ -import {MapArrayType} from "../../../node/types/MapType"; - -import {timesLimit} from '../../../node/utils/promises'; -import {describe, it, expect} from "vitest"; +const assert = require('assert').strict; +const promises = require('../../../node/utils/promises'); describe(__filename, function () { describe('promises.timesLimit', function () { let wantIndex = 0; - - type TestPromise = { - promise?: Promise, - resolve?: () => void, - } - - const testPromises: TestPromise[] = []; - const makePromise = (index: number) => { + const testPromises = []; + const makePromise = (index) => { // Make sure index increases by one each time. - expect(index).toEqual(wantIndex++); + assert.equal(index, wantIndex++); // Save the resolve callback (so the test can trigger resolution) // and the promise itself (to wait for resolve to take effect). - const p:TestPromise = {}; - p.promise = new Promise((resolve) => { + const p = {}; + const promise = new Promise((resolve) => { p.resolve = resolve; }); + p.promise = promise; testPromises.push(p); return p.promise; }; const total = 11; const concurrency = 7; - const timesLimitPromise = timesLimit(total, concurrency, makePromise); + const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise); it('honors concurrency', async function () { - expect(wantIndex).toEqual(concurrency); + assert.equal(wantIndex, concurrency); }); it('creates another when one completes', async function () { - const {promise, resolve} = testPromises.shift()!; - resolve!(); + const {promise, resolve} = testPromises.shift(); + resolve(); await promise; - expect(wantIndex).toEqual(concurrency + 1); + assert.equal(wantIndex, concurrency + 1); }); it('creates the expected total number of promises', async function () { @@ -46,10 +39,10 @@ describe(__filename, function () { // Resolve them in random order to ensure that the resolution order doesn't matter. const i = Math.floor(Math.random() * Math.floor(testPromises.length)); const {promise, resolve} = testPromises.splice(i, 1)[0]; - resolve!(); + resolve(); await promise; } - expect(wantIndex).toEqual(total); + assert.equal(wantIndex, total); }); it('resolves', async function () { @@ -58,35 +51,35 @@ describe(__filename, function () { it('does not create too many promises if total < concurrency', async function () { wantIndex = 0; - expect(testPromises.length).toEqual(0); + assert.equal(testPromises.length, 0); const total = 7; const concurrency = 11; - const timesLimitPromise = timesLimit(total, concurrency, makePromise); + const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise); while (testPromises.length > 0) { - const {promise, resolve} = testPromises.pop()!; - resolve!(); + const {promise, resolve} = testPromises.pop(); + resolve(); await promise; } await timesLimitPromise; - expect(wantIndex).toEqual(total); + assert.equal(wantIndex, total); }); it('accepts total === 0, concurrency > 0', async function () { wantIndex = 0; - expect(testPromises.length).toEqual(0); - await timesLimit(0, concurrency, makePromise); - expect(wantIndex).toEqual(0); + assert.equal(testPromises.length, 0); + await promises.timesLimit(0, concurrency, makePromise); + assert.equal(wantIndex, 0); }); it('accepts total === 0, concurrency === 0', async function () { wantIndex = 0; - expect(testPromises.length).toEqual(0); - await timesLimit(0, 0, makePromise); - expect(wantIndex).toEqual(0); + assert.equal(testPromises.length, 0); + await promises.timesLimit(0, 0, makePromise); + assert.equal(wantIndex, 0); }); it('rejects total > 0, concurrency === 0', async function () { - expect(timesLimit(total, 0, makePromise)).rejects.toThrow(RangeError); + await assert.rejects(promises.timesLimit(total, 0, makePromise), RangeError); }); }); }); diff --git a/src/tests/backend/specs/regression-db.ts b/src/tests/backend/specs/regression-db.ts deleted file mode 100644 index ba50e5240..000000000 --- a/src/tests/backend/specs/regression-db.ts +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -const AuthorManager = require('../../../node/db/AuthorManager'); -import {strict as assert} from "assert"; -const common = require('../common'); -const db = require('../../../node/db/DB'); - -describe(__filename, function () { - let setBackup: Function; - - before(async function () { - await common.init(); - setBackup = db.set; - - db.set = async (...args:any) => { - // delay db.set - await new Promise((resolve) => { setTimeout(() => resolve(), 500); }); - return await setBackup.call(db, ...args); - }; - }); - - after(async function () { - db.set = setBackup; - }); - - it('regression test for missing await in createAuthor (#5000)', async function () { - const {authorID} = await AuthorManager.createAuthor(); // Should block until db.set() finishes. - assert(await AuthorManager.doesAuthorExist(authorID)); - }); -}); diff --git a/src/tests/backend/specs/settings.json b/src/tests/backend/specs/settings.json deleted file mode 100644 index 12b4748c0..000000000 --- a/src/tests/backend/specs/settings.json +++ /dev/null @@ -1,39 +0,0 @@ -// line comment -/* - * block comment - */ -{ - "trailing commas": { - "lists": { - "multiple lines": [ - "", - ] - }, - "objects": { - "multiple lines": { - "key": "", - } - } - }, - "environment variable substitution": { - "set": { - "true": "${SET_VAR_TRUE}", - "false": "${SET_VAR_FALSE}", - "null": "${SET_VAR_NULL}", - "undefined": "${SET_VAR_UNDEFINED}", - "number": "${SET_VAR_NUMBER}", - "string": "${SET_VAR_STRING}", - "empty string": "${SET_VAR_EMPTY_STRING}" - }, - "unset": { - "no default": "${UNSET_VAR}", - "true": "${UNSET_VAR:true}", - "false": "${UNSET_VAR:false}", - "null": "${UNSET_VAR:null}", - "undefined": "${UNSET_VAR:undefined}", - "number": "${UNSET_VAR:123}", - "string": "${UNSET_VAR:foo}", - "empty string": "${UNSET_VAR:}" - } - } -} diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts deleted file mode 100644 index d6dcaf71a..000000000 --- a/src/tests/backend/specs/settings.ts +++ /dev/null @@ -1,92 +0,0 @@ -'use strict'; - -const assert = require('assert').strict; -const {parseSettings} = require('../../../node/utils/Settings').exportedForTestingOnly; -import path from 'path'; -import process from 'process'; - -describe(__filename, function () { - describe('parseSettings', function () { - let settings: any; - const envVarSubstTestCases = [ - {name: 'true', val: 'true', var: 'SET_VAR_TRUE', want: true}, - {name: 'false', val: 'false', var: 'SET_VAR_FALSE', want: false}, - {name: 'null', val: 'null', var: 'SET_VAR_NULL', want: null}, - {name: 'undefined', val: 'undefined', var: 'SET_VAR_UNDEFINED', want: undefined}, - {name: 'number', val: '123', var: 'SET_VAR_NUMBER', want: 123}, - {name: 'string', val: 'foo', var: 'SET_VAR_STRING', want: 'foo'}, - {name: 'empty string', val: '', var: 'SET_VAR_EMPTY_STRING', want: ''}, - ]; - - before(async function () { - for (const tc of envVarSubstTestCases) process.env[tc.var] = tc.val; - delete process.env.UNSET_VAR; - settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert(settings != null); - }); - - describe('environment variable substitution', function () { - describe('set', function () { - for (const tc of envVarSubstTestCases) { - it(tc.name, async function () { - const obj = settings['environment variable substitution'].set; - if (tc.name === 'undefined') { - assert(!(tc.name in obj)); - } else { - assert.equal(obj[tc.name], tc.want); - } - }); - } - }); - - describe('unset', function () { - it('no default', async function () { - const obj = settings['environment variable substitution'].unset; - assert.equal(obj['no default'], null); - }); - - for (const tc of envVarSubstTestCases) { - it(tc.name, async function () { - const obj = settings['environment variable substitution'].unset; - if (tc.name === 'undefined') { - assert(!(tc.name in obj)); - } else { - assert.equal(obj[tc.name], tc.want); - } - }); - } - }); - }); - }); - - - describe("Parse plugin settings", function () { - - before(async function () { - process.env["EP__ADMIN__PASSWORD"] = "test" - }) - - it('should parse plugin settings', async function () { - let settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert.equal(settings.ADMIN.PASSWORD, "test"); - }) - - it('should bundle settings with same path', async function () { - process.env["EP__ADMIN__USERNAME"] = "test" - let settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert.deepEqual(settings.ADMIN, {PASSWORD: "test", USERNAME: "test"}); - }) - - it("Can set the ep themes", async function () { - process.env["EP__ep_themes__default_theme"] = "hacker" - let settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert.deepEqual(settings.ep_themes, {"default_theme": "hacker"}); - }) - - it("can set the ep_webrtc settings", async function () { - process.env["EP__ep_webrtc__enabled"] = "true" - let settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert.deepEqual(settings.ep_webrtc, {"enabled": true}); - }) - }) -}); diff --git a/src/tests/backend/specs/socketio.ts b/src/tests/backend/specs/socketio.js similarity index 51% rename from src/tests/backend/specs/socketio.ts rename to src/tests/backend/specs/socketio.js index cde554e5e..fdb578b55 100644 --- a/src/tests/backend/specs/socketio.ts +++ b/src/tests/backend/specs/socketio.js @@ -1,20 +1,98 @@ 'use strict'; -import {MapArrayType} from "../../../node/types/MapType"; - const assert = require('assert').strict; const common = require('../common'); +const io = require('socket.io-client'); const padManager = require('../../../node/db/PadManager'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const readOnlyManager = require('../../../node/db/ReadOnlyManager'); +const setCookieParser = require('set-cookie-parser'); const settings = require('../../../node/utils/Settings'); -const socketIoRouter = require('../../../node/handler/SocketIORouter'); + +const logger = common.logger; + +// Waits for and returns the next named socket.io event. Rejects if there is any error while waiting +// (unless waiting for that error event). +const getSocketEvent = async (socket, event) => { + const errorEvents = [ + 'error', + 'connect_error', + 'connect_timeout', + 'reconnect_error', + 'reconnect_failed', + ]; + const handlers = {}; + let timeoutId; + return new Promise((resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error(`timed out waiting for ${event} event`)), 1000); + for (const event of errorEvents) { + handlers[event] = (errorString) => { + logger.debug(`socket.io ${event} event: ${errorString}`); + reject(new Error(errorString)); + }; + } + // This will overwrite one of the above handlers if the user is waiting for an error event. + handlers[event] = (...args) => { + logger.debug(`socket.io ${event} event`); + if (args.length > 1) return resolve(args); + resolve(args[0]); + }; + Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler)); + }).finally(() => { + clearTimeout(timeoutId); + Object.entries(handlers).forEach(([event, handler]) => socket.off(event, handler)); + }); +}; + +// Establishes a new socket.io connection. Passes the cookies from the `set-cookie` header(s) in +// `res` (which may be nullish) to the server. Returns a socket.io Socket object. +const connect = async (res) => { + // Convert the `set-cookie` header(s) into a `cookie` header. + const resCookies = (res == null) ? {} : setCookieParser.parse(res, {map: true}); + const reqCookieHdr = Object.entries(resCookies).map( + ([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; '); + + logger.debug('socket.io connecting...'); + const socket = io(`${common.baseUrl}/`, { + forceNew: true, // Different tests will have different query parameters. + path: '/socket.io', + // socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the + // express_sid cookie must be passed as a query parameter. + query: {cookie: reqCookieHdr}, + }); + try { + await getSocketEvent(socket, 'connect'); + } catch (e) { + socket.close(); + throw e; + } + logger.debug('socket.io connected'); + + return socket; +}; + +// Helper function to exchange CLIENT_READY+CLIENT_VARS messages for the named pad. +// Returns the CLIENT_VARS message from the server. +const handshake = async (socket, padID) => { + logger.debug('sending CLIENT_READY...'); + socket.send({ + component: 'pad', + type: 'CLIENT_READY', + padId: padID, + sessionID: null, + token: 't.12345', + protocolVersion: 2, + }); + logger.debug('waiting for CLIENT_VARS response...'); + const msg = await getSocketEvent(socket, 'message'); + logger.debug('received CLIENT_VARS message'); + return msg; +}; describe(__filename, function () { this.timeout(30000); - let agent: any; - let authorize:Function; - const backups:MapArrayType = {}; + let agent; + let authorize; + const backups = {}; const cleanUpPads = async () => { const padIds = ['pad', 'other-pad', 'päd']; await Promise.all(padIds.map(async (padId) => { @@ -24,7 +102,7 @@ describe(__filename, function () { } })); }; - let socket:any; + let socket; before(async function () { agent = await common.init(); }); beforeEach(async function () { @@ -46,7 +124,7 @@ describe(__filename, function () { }; assert(socket == null); authorize = () => true; - plugins.hooks.authorize = [{hook_fn: (hookName: string, {req}:any, cb:Function) => cb([authorize(req)])}]; + plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => cb([authorize(req)])}]; await cleanUpPads(); }); afterEach(async function () { @@ -59,64 +137,44 @@ describe(__filename, function () { describe('Normal accesses', function () { it('!authn anonymous cookie /p/pad -> 200, ok', async function () { + this.timeout(600); const res = await agent.get('/p/pad').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); it('!authn !cookie -> ok', async function () { - socket = await common.connect(null); - const clientVars = await common.handshake(socket, 'pad'); + this.timeout(400); + socket = await connect(null); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); it('!authn user /p/pad -> 200, ok', async function () { + this.timeout(400); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); it('authn user /p/pad -> 200, ok', async function () { + this.timeout(400); settings.requireAuthentication = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); - - for (const authn of [false, true]) { - const desc = authn ? 'authn user' : '!authn anonymous'; - it(`${desc} read-only /p/pad -> 200, ok`, async function () { - const get = (ep: string) => { - let res = agent.get(ep); - if (authn) res = res.auth('user', 'user-password'); - return res.expect(200); - }; - settings.requireAuthentication = authn; - let res = await get('/p/pad'); - socket = await common.connect(res); - let clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - const readOnlyId = clientVars.data.readOnlyId; - assert(readOnlyManager.isReadOnlyId(readOnlyId)); - socket.close(); - res = await get(`/p/${readOnlyId}`); - socket = await common.connect(res); - clientVars = await common.handshake(socket, readOnlyId); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, true); - }); - } - it('authz user /p/pad -> 200, ok', async function () { + this.timeout(400); settings.requireAuthentication = true; settings.requireAuthorization = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); it('supports pad names with characters that must be percent-encoded', async function () { + this.timeout(400); settings.requireAuthentication = true; // requireAuthorization is set to true here to guarantee that the user's padAuthorizations // object is populated. Technically this isn't necessary because the user's padAuthorizations @@ -125,56 +183,40 @@ describe(__filename, function () { settings.requireAuthorization = true; const encodedPadId = encodeURIComponent('päd'); const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'päd'); + socket = await connect(res); + const clientVars = await handshake(socket, 'päd'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); }); describe('Abnormal access attempts', function () { it('authn anonymous /p/pad -> 401, error', async function () { + this.timeout(400); settings.requireAuthentication = true; const res = await agent.get('/p/pad').expect(401); // Despite the 401, try to create the pad via a socket.io connection anyway. - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); + socket = await connect(res); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); - - it('authn anonymous read-only /p/pad -> 401, error', async function () { - settings.requireAuthentication = true; - let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - const readOnlyId = clientVars.data.readOnlyId; - assert(readOnlyManager.isReadOnlyId(readOnlyId)); - socket.close(); - res = await agent.get(`/p/${readOnlyId}`).expect(401); - // Despite the 401, try to read the pad via a socket.io connection anyway. - socket = await common.connect(res); - const message = await common.handshake(socket, readOnlyId); - assert.equal(message.accessStatus, 'deny'); - }); - it('authn !cookie -> error', async function () { + this.timeout(400); settings.requireAuthentication = true; - socket = await common.connect(null); - const message = await common.handshake(socket, 'pad'); + socket = await connect(null); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it('authorization bypass attempt -> error', async function () { + this.timeout(400); // Only allowed to access /p/pad. - authorize = (req:{ - path: string, - }) => req.path === '/p/pad'; + authorize = (req) => req.path === '/p/pad'; settings.requireAuthentication = true; settings.requireAuthorization = true; // First authenticate and establish a session. const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); + socket = await connect(res); // Accessing /p/other-pad should fail, despite the successful fetch of /p/pad. - const message = await common.handshake(socket, 'other-pad'); + const message = await handshake(socket, 'other-pad'); assert.equal(message.accessStatus, 'deny'); }); }); @@ -186,59 +228,66 @@ describe(__filename, function () { }); it("level='create' -> can create", async function () { + this.timeout(400); authorize = () => 'create'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); it('level=true -> can create', async function () { + this.timeout(400); authorize = () => true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); it("level='modify' -> can modify", async function () { + this.timeout(400); await padManager.getPad('pad'); // Create the pad. authorize = () => 'modify'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); it("level='create' settings.editOnly=true -> unable to create", async function () { + this.timeout(400); authorize = () => 'create'; settings.editOnly = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); + socket = await connect(res); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it("level='modify' settings.editOnly=false -> unable to create", async function () { + this.timeout(400); authorize = () => 'modify'; settings.editOnly = false; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); + socket = await connect(res); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it("level='readOnly' -> unable to create", async function () { + this.timeout(400); authorize = () => 'readOnly'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); + socket = await connect(res); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it("level='readOnly' -> unable to modify", async function () { + this.timeout(400); await padManager.getPad('pad'); // Create the pad. authorize = () => 'readOnly'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, true); }); @@ -250,50 +299,56 @@ describe(__filename, function () { }); it('user.canCreate = true -> can create and modify', async function () { + this.timeout(400); settings.users.user.canCreate = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); it('user.canCreate = false -> unable to create', async function () { + this.timeout(400); settings.users.user.canCreate = false; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); + socket = await connect(res); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it('user.readOnly = true -> unable to create', async function () { + this.timeout(400); settings.users.user.readOnly = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); + socket = await connect(res); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it('user.readOnly = true -> unable to modify', async function () { + this.timeout(400); await padManager.getPad('pad'); // Create the pad. settings.users.user.readOnly = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, true); }); it('user.readOnly = false -> can create and modify', async function () { + this.timeout(400); settings.users.user.readOnly = false; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); it('user.readOnly = true, user.canCreate = true -> unable to create', async function () { + this.timeout(400); settings.users.user.canCreate = true; settings.users.user.readOnly = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); + socket = await connect(res); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); }); @@ -305,130 +360,23 @@ describe(__filename, function () { }); it('authorize hook does not elevate level from user settings', async function () { + this.timeout(400); settings.users.user.readOnly = true; authorize = () => 'create'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); + socket = await connect(res); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it('user settings does not elevate level from authorize hook', async function () { + this.timeout(400); settings.users.user.readOnly = false; settings.users.user.canCreate = true; authorize = () => 'readOnly'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); + socket = await connect(res); + const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); }); - - describe('SocketIORouter.js', function () { - const Module = class { - setSocketIO(io:any) {} - handleConnect(socket:any) {} - handleDisconnect(socket:any) {} - handleMessage(socket:any, message:string) {} - }; - - afterEach(async function () { - socketIoRouter.deleteComponent(this.test!.fullTitle()); - socketIoRouter.deleteComponent(`${this.test!.fullTitle()} #2`); - }); - - it('setSocketIO', async function () { - let ioServer; - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - setSocketIO(io:any) { ioServer = io; } - }()); - assert(ioServer != null); - }); - - it('handleConnect', async function () { - let serverSocket; - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - handleConnect(socket:any) { serverSocket = socket; } - }()); - socket = await common.connect(); - assert(serverSocket != null); - }); - - it('handleDisconnect', async function () { - let resolveConnected: (value: void | PromiseLike) => void ; - const connected = new Promise((resolve) => resolveConnected = resolve); - let resolveDisconnected: (value: void | PromiseLike) => void ; - const disconnected = new Promise((resolve) => resolveDisconnected = resolve); - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - private _socket: any; - handleConnect(socket:any) { - this._socket = socket; - resolveConnected(); - } - handleDisconnect(socket:any) { - assert(socket != null); - // There might be lingering disconnect events from sockets created by other tests. - if (this._socket == null || socket.id !== this._socket.id) return; - assert.equal(socket, this._socket); - resolveDisconnected(); - } - }()); - socket = await common.connect(); - await connected; - socket.close(); - socket = null; - await disconnected; - }); - - it('handleMessage (success)', async function () { - let serverSocket:any; - const want = { - component: this.test!.fullTitle(), - foo: {bar: 'asdf'}, - }; - let rx:Function; - const got = new Promise((resolve) => { rx = resolve; }); - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - handleConnect(socket:any) { serverSocket = socket; } - handleMessage(socket:any, message:string) { assert.equal(socket, serverSocket); rx(message); } - }()); - socketIoRouter.addComponent(`${this.test!.fullTitle()} #2`, new class extends Module { - handleMessage(socket:any, message:any) { assert.fail('wrong handler called'); } - }()); - socket = await common.connect(); - socket.emit('message', want); - assert.deepEqual(await got, want); - }); - - const tx = async (socket:any, message = {}) => await new Promise((resolve, reject) => { - const AckErr = class extends Error { - constructor(name: string, ...args:any) { super(...args); this.name = name; } - }; - socket.emit('message', message, - (errj: { - message: string, - name: string, - }, val: any) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val)); - }); - - it('handleMessage with ack (success)', async function () { - const want = 'value'; - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - handleMessage(socket:any, msg:any) { return want; } - }()); - socket = await common.connect(); - const got = await tx(socket, {component: this.test!.fullTitle()}); - assert.equal(got, want); - }); - - it('handleMessage with ack (error)', async function () { - const InjectedError = class extends Error { - constructor() { super('injected test error'); this.name = 'InjectedError'; } - }; - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - handleMessage(socket:any, msg:any) { throw new InjectedError(); } - }()); - socket = await common.connect(); - await assert.rejects(tx(socket, {component: this.test!.fullTitle()}), new InjectedError()); - }); - }); }); diff --git a/src/tests/backend/specs/specialpages.ts b/src/tests/backend/specs/specialpages.js similarity index 84% rename from src/tests/backend/specs/specialpages.ts rename to src/tests/backend/specs/specialpages.js index fbb446c49..8b372c959 100644 --- a/src/tests/backend/specs/specialpages.ts +++ b/src/tests/backend/specs/specialpages.js @@ -1,16 +1,10 @@ -'use strict'; - -import {MapArrayType} from "../../../node/types/MapType"; - const common = require('../common'); const settings = require('../../../node/utils/Settings'); - - describe(__filename, function () { this.timeout(30000); - let agent:any; - const backups:MapArrayType = {}; + let agent; + const backups = {}; before(async function () { agent = await common.init(); }); beforeEach(async function () { backups.settings = {}; @@ -26,6 +20,7 @@ describe(__filename, function () { describe('/javascript', function () { it('/javascript -> 200', async function () { + this.timeout(200); await agent.get('/javascript').expect(200); }); }); diff --git a/src/tests/backend/specs/webaccess.ts b/src/tests/backend/specs/webaccess.js similarity index 81% rename from src/tests/backend/specs/webaccess.ts rename to src/tests/backend/specs/webaccess.js index 96c2265fc..fe8c4c5c9 100644 --- a/src/tests/backend/specs/webaccess.ts +++ b/src/tests/backend/specs/webaccess.js @@ -1,9 +1,5 @@ 'use strict'; -import {MapArrayType} from "../../../node/types/MapType"; -import {Func} from "mocha"; -import {SettingsUser} from "../../../node/types/SettingsUser"; - const assert = require('assert').strict; const common = require('../common'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); @@ -11,11 +7,11 @@ const settings = require('../../../node/utils/Settings'); describe(__filename, function () { this.timeout(30000); - let agent:any; - const backups:MapArrayType = {}; + let agent; + const backups = {}; const authHookNames = ['preAuthorize', 'authenticate', 'authorize']; const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure']; - const makeHook = (hookName: string, hookFn:Function) => ({ + const makeHook = (hookName, hookFn) => ({ hook_fn: hookFn, hook_fn_name: `fake_plugin/${hookName}`, hook_name: hookName, @@ -23,7 +19,6 @@ describe(__filename, function () { }); before(async function () { agent = await common.init(); }); - beforeEach(async function () { backups.hooks = {}; for (const hookName of authHookNames.concat(failHookNames)) { @@ -39,9 +34,8 @@ describe(__filename, function () { settings.users = { admin: {password: 'admin-password', is_admin: true}, user: {password: 'user-password'}, - } satisfies SettingsUser; + }; }); - afterEach(async function () { Object.assign(plugins.hooks, backups.hooks); Object.assign(settings, backups.settings); @@ -49,75 +43,70 @@ describe(__filename, function () { describe('webaccess: without plugins', function () { it('!authn !authz anonymous / -> 200', async function () { + this.timeout(150); settings.requireAuthentication = false; settings.requireAuthorization = false; await agent.get('/').expect(200); }); - - it('!authn !authz anonymous /admin-auth// -> 401', async function () { + it('!authn !authz anonymous /admin/ -> 401', async function () { + this.timeout(100); settings.requireAuthentication = false; settings.requireAuthorization = false; - await agent.get('/admin-auth/').expect(401); + await agent.get('/admin/').expect(401); }); - it('authn !authz anonymous / -> 401', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = false; await agent.get('/').expect(401); }); - it('authn !authz user / -> 200', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = false; await agent.get('/').auth('user', 'user-password').expect(200); }); - - it('authn !authz user //admin-auth// -> 403', async function () { + it('authn !authz user /admin/ -> 403', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = false; - await agent.get('/admin-auth//').auth('user', 'user-password').expect(403); + await agent.get('/admin/').auth('user', 'user-password').expect(403); }); - it('authn !authz admin / -> 200', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = false; await agent.get('/').auth('admin', 'admin-password').expect(200); }); - - it('authn !authz admin /admin-auth/ -> 200', async function () { + it('authn !authz admin /admin/ -> 200', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = false; - await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200); + await agent.get('/admin/').auth('admin', 'admin-password').expect(200); }); - - it('authn authz anonymous /robots.txt -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/robots.txt').expect(200); - }); - it('authn authz user / -> 403', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = true; await agent.get('/').auth('user', 'user-password').expect(403); }); - - it('authn authz user //admin-auth// -> 403', async function () { + it('authn authz user /admin/ -> 403', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = true; - await agent.get('/admin-auth//').auth('user', 'user-password').expect(403); + await agent.get('/admin/').auth('user', 'user-password').expect(403); }); - it('authn authz admin / -> 200', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = true; await agent.get('/').auth('admin', 'admin-password').expect(200); }); - - it('authn authz admin /admin-auth/ -> 200', async function () { + it('authn authz admin /admin/ -> 200', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = true; - await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200); + await agent.get('/admin/').auth('admin', 'admin-password').expect(200); }); describe('login fails if password is nullish', function () { @@ -128,9 +117,10 @@ describe(__filename, function () { // parsing, resulting in successful comparisons against a null or undefined password. for (const creds of ['admin', 'admin:']) { it(`admin password: ${adminPassword} credentials: ${creds}`, async function () { + this.timeout(100); settings.users.admin.password = adminPassword; const encCreds = Buffer.from(creds).toString('base64'); - await agent.get('/admin-auth/').set('Authorization', `Basic ${encCreds}`).expect(401); + await agent.get('/admin/').set('Authorization', `Basic ${encCreds}`).expect(401); }); } } @@ -138,21 +128,16 @@ describe(__filename, function () { }); describe('webaccess: preAuthorize, authenticate, and authorize hooks', function () { - let callOrder:string[]; + let callOrder; const Handler = class { - private called: boolean; - private readonly hookName: string; - private readonly innerHandle: Function; - private readonly id: string; - private readonly checkContext: Function; - constructor(hookName:string, suffix: string) { + constructor(hookName, suffix) { this.called = false; this.hookName = hookName; this.innerHandle = () => []; this.id = hookName + suffix; this.checkContext = () => {}; } - handle(hookName: string, context: any, cb:Function) { + handle(hookName, context, cb) { assert.equal(hookName, this.hookName); assert(context != null); assert(context.req != null); @@ -162,10 +147,10 @@ describe(__filename, function () { assert(!this.called); this.called = true; callOrder.push(this.id); - return cb(this.innerHandle(context)); + return cb(this.innerHandle(context.req)); } }; - const handlers:MapArrayType = {}; + const handlers = {}; beforeEach(async function () { callOrder = []; @@ -188,68 +173,59 @@ describe(__filename, function () { }); it('defers if it returns []', async function () { + this.timeout(100); await agent.get('/').expect(200); // Note: The preAuthorize hook always runs even if requireAuthorization is false. assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); }); - it('bypasses authenticate and authorize hooks when true is returned', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = true; handlers.preAuthorize[0].innerHandle = () => [true]; await agent.get('/').expect(200); assert.deepEqual(callOrder, ['preAuthorize_0']); }); - it('bypasses authenticate and authorize hooks when false is returned', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = true; handlers.preAuthorize[0].innerHandle = () => [false]; await agent.get('/').expect(403); assert.deepEqual(callOrder, ['preAuthorize_0']); }); - - it('bypasses authenticate and authorize hooks when next is called', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - handlers.preAuthorize[0].innerHandle = ({next}:{ - next: Function - }) => next(); - await agent.get('/').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0']); - }); - - it('static content (expressPreSession) bypasses all auth checks', async function () { + it('bypasses authenticate and authorize hooks for static content, defers', async function () { + this.timeout(100); settings.requireAuthentication = true; settings.requireAuthorization = true; await agent.get('/static/robots.txt').expect(200); - assert.deepEqual(callOrder, []); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); }); - it('cannot grant access to /admin', async function () { + this.timeout(100); handlers.preAuthorize[0].innerHandle = () => [true]; - await agent.get('/admin-auth/').expect(401); + await agent.get('/admin/').expect(401); // Notes: // * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because - // 'true' entries are ignored for /admin-auth//* requests. - // * The authenticate hook always runs for /admin-auth//* requests even if + // 'true' entries are ignored for /admin/* requests. + // * The authenticate hook always runs for /admin/* requests even if // settings.requireAuthentication is false. assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0', 'authenticate_1']); }); - - it('can deny access to /admin-auth/', async function () { + it('can deny access to /admin', async function () { + this.timeout(100); handlers.preAuthorize[0].innerHandle = () => [false]; - await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(403); + await agent.get('/admin/').auth('admin', 'admin-password').expect(403); assert.deepEqual(callOrder, ['preAuthorize_0']); }); - it('runs preAuthzFailure hook when access is denied', async function () { + this.timeout(100); handlers.preAuthorize[0].innerHandle = () => [false]; let called = false; - plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName: string, {req, res}:any, cb:Function) => { + plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName, {req, res}, cb) => { assert.equal(hookName, 'preAuthzFailure'); assert(req != null); assert(res != null); @@ -258,11 +234,11 @@ describe(__filename, function () { res.status(200).send('injected'); return cb([true]); })]; - await agent.get('/admin-auth//').auth('admin', 'admin-password').expect(200, 'injected'); + await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected'); assert(called); }); - it('returns 500 if an exception is thrown', async function () { + this.timeout(100); handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); }; await agent.get('/').expect(500); }); @@ -274,55 +250,54 @@ describe(__filename, function () { settings.requireAuthorization = false; }); - it('is not called if !requireAuthentication and not /admin-auth/*', async function () { + it('is not called if !requireAuthentication and not /admin/*', async function () { + this.timeout(100); settings.requireAuthentication = false; await agent.get('/').expect(200); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); }); - - it('is called if !requireAuthentication and /admin-auth//*', async function () { + it('is called if !requireAuthentication and /admin/*', async function () { + this.timeout(100); settings.requireAuthentication = false; - await agent.get('/admin-auth/').expect(401); + await agent.get('/admin/').expect(401); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0', 'authenticate_1']); }); - it('defers if empty list returned', async function () { + this.timeout(100); await agent.get('/').expect(401); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0', 'authenticate_1']); }); - it('does not defer if return [true], 200', async function () { - handlers.authenticate[0].innerHandle = ({req}:any) => { req.session.user = {}; return [true]; }; + this.timeout(100); + handlers.authenticate[0].innerHandle = (req) => { req.session.user = {}; return [true]; }; await agent.get('/').expect(200); // Note: authenticate_1 was not called because authenticate_0 handled it. assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); }); - it('does not defer if return [false], 401', async function () { - handlers.authenticate[0].innerHandle = () => [false]; + this.timeout(100); + handlers.authenticate[0].innerHandle = (req) => [false]; await agent.get('/').expect(401); // Note: authenticate_1 was not called because authenticate_0 handled it. assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); }); - it('falls back to HTTP basic auth', async function () { + this.timeout(100); await agent.get('/').auth('user', 'user-password').expect(200); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0', 'authenticate_1']); }); - it('passes settings.users in context', async function () { - handlers.authenticate[0].checkContext = ({users}:{ - users: SettingsUser - }) => { + this.timeout(100); + handlers.authenticate[0].checkContext = ({users}) => { assert.equal(users, settings.users); }; await agent.get('/').expect(401); @@ -331,13 +306,9 @@ describe(__filename, function () { 'authenticate_0', 'authenticate_1']); }); - it('passes user, password in context if provided', async function () { - handlers.authenticate[0].checkContext = ({username, password}:{ - username: string, - password: string - - }) => { + this.timeout(100); + handlers.authenticate[0].checkContext = ({username, password}) => { assert.equal(username, 'user'); assert.equal(password, 'user-password'); }; @@ -347,12 +318,9 @@ describe(__filename, function () { 'authenticate_0', 'authenticate_1']); }); - it('does not pass user, password in context if not provided', async function () { - handlers.authenticate[0].checkContext = ({username, password}:{ - username: string, - password: string - }) => { + this.timeout(100); + handlers.authenticate[0].checkContext = ({username, password}) => { assert(username == null); assert(password == null); }; @@ -362,14 +330,14 @@ describe(__filename, function () { 'authenticate_0', 'authenticate_1']); }); - it('errors if req.session.user is not created', async function () { + this.timeout(100); handlers.authenticate[0].innerHandle = () => [true]; await agent.get('/').expect(500); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); }); - it('returns 500 if an exception is thrown', async function () { + this.timeout(100); handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); }; await agent.get('/').expect(500); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); @@ -383,6 +351,7 @@ describe(__filename, function () { }); it('is not called if !requireAuthorization (non-/admin)', async function () { + this.timeout(100); settings.requireAuthorization = false; await agent.get('/').auth('user', 'user-password').expect(200); assert.deepEqual(callOrder, ['preAuthorize_0', @@ -390,17 +359,17 @@ describe(__filename, function () { 'authenticate_0', 'authenticate_1']); }); - it('is not called if !requireAuthorization (/admin)', async function () { + this.timeout(100); settings.requireAuthorization = false; - await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200); + await agent.get('/admin/').auth('admin', 'admin-password').expect(200); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0', 'authenticate_1']); }); - it('defers if empty list returned', async function () { + this.timeout(100); await agent.get('/').auth('user', 'user-password').expect(403); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', @@ -409,8 +378,8 @@ describe(__filename, function () { 'authorize_0', 'authorize_1']); }); - it('does not defer if return [true], 200', async function () { + this.timeout(100); handlers.authorize[0].innerHandle = () => [true]; await agent.get('/').auth('user', 'user-password').expect(200); // Note: authorize_1 was not called because authorize_0 handled it. @@ -420,9 +389,9 @@ describe(__filename, function () { 'authenticate_1', 'authorize_0']); }); - it('does not defer if return [false], 403', async function () { - handlers.authorize[0].innerHandle = () => [false]; + this.timeout(100); + handlers.authorize[0].innerHandle = (req) => [false]; await agent.get('/').auth('user', 'user-password').expect(403); // Note: authorize_1 was not called because authorize_0 handled it. assert.deepEqual(callOrder, ['preAuthorize_0', @@ -431,11 +400,9 @@ describe(__filename, function () { 'authenticate_1', 'authorize_0']); }); - it('passes req.path in context', async function () { - handlers.authorize[0].checkContext = ({resource}:{ - resource: string - }) => { + this.timeout(100); + handlers.authorize[0].checkContext = ({resource}) => { assert.equal(resource, '/'); }; await agent.get('/').auth('user', 'user-password').expect(403); @@ -446,8 +413,8 @@ describe(__filename, function () { 'authorize_0', 'authorize_1']); }); - it('returns 500 if an exception is thrown', async function () { + this.timeout(100); handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); }; await agent.get('/').auth('user', 'user-password').expect(500); assert.deepEqual(callOrder, ['preAuthorize_0', @@ -461,15 +428,12 @@ describe(__filename, function () { describe('webaccess: authnFailure, authzFailure, authFailure hooks', function () { const Handler = class { - private hookName: string; - private shouldHandle: boolean; - private called: boolean; - constructor(hookName: string) { + constructor(hookName) { this.hookName = hookName; this.shouldHandle = false; this.called = false; } - handle(hookName: string, context:any, cb: Function) { + handle(hookName, context, cb) { assert.equal(hookName, this.hookName); assert(context != null); assert(context.req != null); @@ -483,7 +447,7 @@ describe(__filename, function () { return cb([]); } }; - const handlers:MapArrayType = {}; + const handlers = {}; beforeEach(async function () { failHookNames.forEach((hookName) => { @@ -497,29 +461,30 @@ describe(__filename, function () { // authn failure tests it('authn fail, no hooks handle -> 401', async function () { + this.timeout(100); await agent.get('/').expect(401); assert(handlers.authnFailure.called); assert(!handlers.authzFailure.called); assert(handlers.authFailure.called); }); - it('authn fail, authnFailure handles', async function () { + this.timeout(100); handlers.authnFailure.shouldHandle = true; await agent.get('/').expect(200, 'authnFailure'); assert(handlers.authnFailure.called); assert(!handlers.authzFailure.called); assert(!handlers.authFailure.called); }); - it('authn fail, authFailure handles', async function () { + this.timeout(100); handlers.authFailure.shouldHandle = true; await agent.get('/').expect(200, 'authFailure'); assert(handlers.authnFailure.called); assert(!handlers.authzFailure.called); assert(handlers.authFailure.called); }); - it('authnFailure trumps authFailure', async function () { + this.timeout(100); handlers.authnFailure.shouldHandle = true; handlers.authFailure.shouldHandle = true; await agent.get('/').expect(200, 'authnFailure'); @@ -529,29 +494,30 @@ describe(__filename, function () { // authz failure tests it('authz fail, no hooks handle -> 403', async function () { + this.timeout(100); await agent.get('/').auth('user', 'user-password').expect(403); assert(!handlers.authnFailure.called); assert(handlers.authzFailure.called); assert(handlers.authFailure.called); }); - it('authz fail, authzFailure handles', async function () { + this.timeout(100); handlers.authzFailure.shouldHandle = true; await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure'); assert(!handlers.authnFailure.called); assert(handlers.authzFailure.called); assert(!handlers.authFailure.called); }); - it('authz fail, authFailure handles', async function () { + this.timeout(100); handlers.authFailure.shouldHandle = true; await agent.get('/').auth('user', 'user-password').expect(200, 'authFailure'); assert(!handlers.authnFailure.called); assert(handlers.authzFailure.called); assert(handlers.authFailure.called); }); - it('authzFailure trumps authFailure', async function () { + this.timeout(100); handlers.authzFailure.shouldHandle = true; handlers.authFailure.shouldHandle = true; await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure'); diff --git a/src/tests/container/specs/api/pad.js b/src/tests/container/specs/api/pad.js index f6ff8ebf5..04067f0e3 100644 --- a/src/tests/container/specs/api/pad.js +++ b/src/tests/container/specs/api/pad.js @@ -31,8 +31,8 @@ describe('API Versioning', function () { }); describe('Permission', function () { - it('errors with invalid OAuth token', function (done) { - api.get(`/api/${apiVersion}/createPad?padID=test`) + it('errors with invalid APIKey', function (done) { + api.get(`/api/${apiVersion}/createPad?apikey=wrong_password&padID=test`) .expect(401, done); }); }); diff --git a/src/tests/frontend-new/admin-spec/adminsettings.spec.ts b/src/tests/frontend-new/admin-spec/adminsettings.spec.ts deleted file mode 100644 index 4c28874dc..000000000 --- a/src/tests/frontend-new/admin-spec/adminsettings.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {loginToAdmin, restartEtherpad, saveSettings} from "../helper/adminhelper"; - -test.beforeEach(async ({ page })=>{ - await loginToAdmin(page, 'admin', 'changeme1'); -}) - -test.describe('admin settings',()=> { - - - test('Are Settings visible, populated, does save work', async ({page}) => { - await page.goto('http://localhost:9001/admin/settings'); - await page.waitForSelector('.settings'); - const settings = page.locator('.settings'); - await expect(settings).not.toBeEmpty(); - - const settingsVal = await settings.inputValue() - const settingsLength = settingsVal.length - - await settings.fill(`{"title": "Etherpad123"}`) - const newValue = await settings.inputValue() - expect(newValue).toContain('{"title": "Etherpad123"}') - expect(newValue.length).toEqual(24) - await saveSettings(page) - - // Check if the changes were actually saved - await page.reload() - await page.waitForSelector('.settings'); - await expect(settings).not.toBeEmpty(); - - const newSettings = page.locator('.settings'); - - const newSettingsVal = await newSettings.inputValue() - expect(newSettingsVal).toContain('{"title": "Etherpad123"}') - - - // Change back to old settings - await newSettings.fill(settingsVal) - await saveSettings(page) - - await page.reload() - await page.waitForSelector('.settings'); - await expect(settings).not.toBeEmpty(); - const oldSettings = page.locator('.settings'); - const oldSettingsVal = await oldSettings.inputValue() - expect(oldSettingsVal).toEqual(settingsVal) - expect(oldSettingsVal.length).toEqual(settingsLength) - }) - - test('restart works', async function ({page}) { - await page.goto('http://localhost:9001/admin/settings'); - await page.waitForSelector('.settings') - await restartEtherpad(page) - await page.waitForSelector('.settings') - const settings = page.locator('.settings'); - await expect(settings).not.toBeEmpty(); - await page.waitForSelector('.menu') - await page.waitForTimeout(5000) - }); -}) diff --git a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts deleted file mode 100644 index 9155e9cbd..000000000 --- a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {loginToAdmin} from "../helper/adminhelper"; - -test.beforeEach(async ({ page })=>{ - await loginToAdmin(page, 'admin', 'changeme1'); - await page.goto('http://localhost:9001/admin/help') -}) - -test('Shows troubleshooting page manager', async ({page}) => { - await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - const menu = page.locator('.menu'); - await expect(menu.locator('li')).toHaveCount(5); -}) - -test('Shows a version number', async function ({page}) { - await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - const helper = page.locator('.help-block').locator('div').nth(1) - const version = (await helper.textContent())!.split('.'); - expect(version.length).toBe(3) -}); - -test('Lists installed parts', async function ({page}) { - await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - await page.waitForSelector('.innerwrapper ul') - const parts = page.locator('.innerwrapper ul').nth(1); - expect(await parts.textContent()).toContain('ep_etherpad-lite/adminsettings'); -}); - -test('Lists installed hooks', async function ({page}) { - await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - await page.waitForSelector('.innerwrapper ul') - const helper = page.locator('.innerwrapper ul').nth(2); - expect(await helper.textContent()).toContain('express'); -}); - diff --git a/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts b/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts deleted file mode 100644 index c1121d41b..000000000 --- a/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {loginToAdmin} from "../helper/adminhelper"; - -test.beforeEach(async ({ page })=>{ - await loginToAdmin(page, 'admin', 'changeme1'); - await page.goto('http://localhost:9001/admin/plugins') -}) - - -test.describe('Plugins page', ()=> { - - test('List some plugins', async ({page}) => { - await page.waitForSelector('.search-field'); - const pluginTable = page.locator('table tbody').nth(1); - await expect(pluginTable).not.toBeEmpty() - const plugins = await pluginTable.locator('tr').count() - expect(plugins).toBeGreaterThan(10) - }) - - test('Searches for a plugin', async ({page}) => { - await page.waitForSelector('.search-field'); - await page.click('.search-field') - await page.keyboard.type('ep_font_color3') - await page.keyboard.press('Enter') - const pluginTable = page.locator('table tbody').nth(1); - await expect(pluginTable.locator('tr')).toHaveCount(1) - await expect(pluginTable.locator('tr').first()).toContainText('ep_font_color3') - }) - - - test('Attempt to Install and Uninstall a plugin', async ({page}) => { - await page.waitForSelector('.search-field'); - const pluginTable = page.locator('table tbody').nth(1); - await expect(pluginTable).not.toBeEmpty({ - timeout: 15000 - }) - const plugins = await pluginTable.locator('tr').count() - expect(plugins).toBeGreaterThan(10) - - // Now everything is loaded, lets install a plugin - - await page.click('.search-field') - await page.keyboard.type('ep_font_color3') - await page.keyboard.press('Enter') - - await expect(pluginTable.locator('tr')).toHaveCount(1) - const pluginRow = pluginTable.locator('tr').first() - await expect(pluginRow).toContainText('ep_font_color3') - - // Select Installation button - await pluginRow.locator('td').nth(4).locator('button').first().click() - await page.waitForTimeout(100) - await page.waitForSelector('table tbody') - const installedPlugins = page.locator('table tbody').first() - const installedPluginsRows = installedPlugins.locator('tr') - await expect(installedPluginsRows).toHaveCount(2, { - timeout: 15000 - }) - - const installedPluginRow = installedPluginsRows.nth(1) - - await expect(installedPluginRow).toContainText('ep_font_color3') - await installedPluginRow.locator('td').nth(2).locator('button').first().click() - - // Wait for the uninstallation to complete - await expect(installedPluginsRows).toHaveCount(1, { - timeout: 15000 - }) - await page.waitForTimeout(5000) - }) -}) - - -/* - it('Attempt to Update a plugin', async function () { - this.timeout(280000); - - await helper.waitForPromise(() => helper.admin$('.results').children().length > 50, 20000); - - if (helper.admin$('.ep_align').length === 0) this.skip(); - - await helper.waitForPromise( - () => helper.admin$('.ep_align .version').text().split('.').length >= 2); - - const minorVersionBefore = - parseInt(helper.admin$('.ep_align .version').text().split('.')[1]); - - if (!minorVersionBefore) { - throw new Error('Unable to get minor number of plugin, is the plugin installed?'); - } - - if (minorVersionBefore !== 2) this.skip(); - - helper.waitForPromise( - () => helper.admin$('.ep_align .do-update').length === 1); - - await timeout(500); // HACK! Please submit better fix.. - const $doUpdateButton = helper.admin$('.ep_align .do-update'); - $doUpdateButton.trigger('click'); - - // ensure its showing as Updating - await helper.waitForPromise( - () => helper.admin$('.ep_align .message').text() === 'Updating'); - - // Ensure it's a higher minor version IE 0.3.x as 0.2.x was installed - // Coverage for https://github.com/ether/etherpad-lite/issues/4536 - await helper.waitForPromise(() => parseInt(helper.admin$('.ep_align .version') - .text() - .split('.')[1]) > minorVersionBefore, 60000, 1000); - // allow 50 seconds, check every 1 second. - }); - */ diff --git a/src/tests/frontend-new/helper/adminhelper.ts b/src/tests/frontend-new/helper/adminhelper.ts deleted file mode 100644 index 8f2242f89..000000000 --- a/src/tests/frontend-new/helper/adminhelper.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {expect, Page} from "@playwright/test"; - -export const loginToAdmin = async (page: Page, username: string, password: string) => { - - await page.goto('http://localhost:9001/admin/'); - - await page.waitForSelector('input[name="username"]'); - await page.fill('input[name="username"]', username); - await page.fill('input[name="password"]', password); - await page.click('input[type="submit"]'); -} - - -export const saveSettings = async (page: Page) => { - // Click save - await page.locator('.settings-button-bar').locator('button').first().click() - await page.waitForSelector('.ToastRootSuccess') -} - -export const restartEtherpad = async (page: Page) => { - // Click restart - const restartButton = page.locator('.settings-button-bar').locator('.settingsButton').nth(1) - const settings = page.locator('.settings'); - await expect(settings).not.toBeEmpty(); - await expect(restartButton).toBeVisible() - await page.locator('.settings-button-bar') - .locator('.settingsButton') - .nth(1) - .click() - await page.waitForTimeout(500) - await page.waitForSelector('.settings') -} diff --git a/src/tests/frontend-new/helper/padHelper.ts b/src/tests/frontend-new/helper/padHelper.ts deleted file mode 100644 index f52cd0a35..000000000 --- a/src/tests/frontend-new/helper/padHelper.ts +++ /dev/null @@ -1,157 +0,0 @@ -import {Frame, Locator, Page} from "@playwright/test"; -import {MapArrayType} from "../../../node/types/MapType"; -import {randomUUID} from "node:crypto"; - -export const getPadOuter = async (page: Page): Promise => { - return page.frame('ace_outer')!; -} - -export const getPadBody = async (page: Page): Promise => { - return page.frame('ace_inner')!.locator('#innerdocbody') -} - -export const selectAllText = async (page: Page) => { - await page.keyboard.down('Control'); - await page.keyboard.press('A'); - await page.keyboard.up('Control'); -} - -export const toggleUserList = async (page: Page) => { - await page.locator("button[data-l10n-id='pad.toolbar.showusers.title']").click() -} - -export const setUserName = async (page: Page, userName: string) => { - await page.waitForSelector('[class="popup popup-show"]') - await page.click("input[data-l10n-id='pad.userlist.entername']"); - await page.keyboard.type(userName); -} - - -export const showChat = async (page: Page) => { - const chatIcon = page.locator("#chaticon") - const classes = await chatIcon.getAttribute('class') - if (classes && !classes.includes('visible')) return - await chatIcon.click() - await page.waitForFunction(`!document.querySelector('#chaticon').classList.contains('visible')`) -} - -export const getCurrentChatMessageCount = async (page: Page) => { - return await page.locator('#chattext').locator('p').count() -} - -export const getChatUserName = async (page: Page) => { - return await page.locator('#chattext') - .locator('p') - .locator('b') - .innerText() -} - -export const getChatMessage = async (page: Page) => { - return (await page.locator('#chattext') - .locator('p') - .textContent({}))! - .split(await getChatTime(page))[1] - -} - - -export const getChatTime = async (page: Page) => { - return await page.locator('#chattext') - .locator('p') - .locator('.time') - .innerText() -} - -export const sendChatMessage = async (page: Page, message: string) => { - let currentChatCount = await getCurrentChatMessageCount(page) - - const chatInput = page.locator('#chatinput') - await chatInput.click() - await page.keyboard.type(message) - await page.keyboard.press('Enter') - if(message === "") return - await page.waitForFunction(`document.querySelector('#chattext').querySelectorAll('p').length >${currentChatCount}`) -} - -export const isChatBoxShown = async (page: Page):Promise => { - const classes = await page.locator('#chatbox').getAttribute('class') - return classes !==null && classes.includes('visible') -} - -export const isChatBoxSticky = async (page: Page):Promise => { - const classes = await page.locator('#chatbox').getAttribute('class') - console.log('Chat', classes && classes.includes('stickyChat')) - return classes !==null && classes.includes('stickyChat') -} - -export const hideChat = async (page: Page) => { - if(!await isChatBoxShown(page)|| await isChatBoxSticky(page)) return - await page.locator('#titlecross').click() - await page.waitForFunction(`!document.querySelector('#chatbox').classList.contains('stickyChat')`) - -} - -export const enableStickyChatviaIcon = async (page: Page) => { - if(await isChatBoxSticky(page)) return - await page.locator('#titlesticky').click() - await page.waitForFunction(`document.querySelector('#chatbox').classList.contains('stickyChat')`) -} - -export const disableStickyChatviaIcon = async (page: Page) => { - if(!await isChatBoxSticky(page)) return - await page.locator('#titlecross').click() - await page.waitForFunction(`!document.querySelector('#chatbox').classList.contains('stickyChat')`) -} - - -export const appendQueryParams = async (page: Page, queryParameters: MapArrayType) => { - const searchParams = new URLSearchParams(page.url().split('?')[1]); - Object.keys(queryParameters).forEach((key) => { - searchParams.append(key, queryParameters[key]); - }); - await page.goto(page.url()+"?"+ searchParams.toString()); - await page.waitForSelector('iframe[name="ace_outer"]'); -} - -export const goToNewPad = async (page: Page) => { - // create a new pad before each test run - const padId = "FRONTEND_TESTS"+randomUUID(); - await page.goto('http://localhost:9001/p/'+padId); - await page.waitForSelector('iframe[name="ace_outer"]'); - return padId; -} - -export const goToPad = async (page: Page, padId: string) => { - await page.goto('http://localhost:9001/p/'+padId); - await page.waitForSelector('iframe[name="ace_outer"]'); -} - - -export const clearPadContent = async (page: Page) => { - const body = await getPadBody(page); - await body.click(); - await page.keyboard.down('Control'); - await page.keyboard.press('A'); - await page.keyboard.up('Control'); - await page.keyboard.press('Delete'); -} - -export const writeToPad = async (page: Page, text: string) => { - const body = await getPadBody(page); - await body.click(); - await page.keyboard.type(text); -} - -export const clearAuthorship = async (page: Page) => { - await page.locator("button[data-l10n-id='pad.toolbar.clearAuthorship.title']").click() -} - -export const undoChanges = async (page: Page) => { - await page.keyboard.down('Control'); - await page.keyboard.press('z'); - await page.keyboard.up('Control'); -} - -export const pressUndoButton = async (page: Page) => { - await page.locator('.buttonicon-undo').click() -} diff --git a/src/tests/frontend-new/helper/settingsHelper.ts b/src/tests/frontend-new/helper/settingsHelper.ts deleted file mode 100644 index 729dd48f6..000000000 --- a/src/tests/frontend-new/helper/settingsHelper.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {Page} from "@playwright/test"; - -export const isSettingsShown = async (page: Page) => { - const classes = await page.locator('#settings').getAttribute('class') - return classes && classes.includes('popup-show') -} - - -export const showSettings = async (page: Page) => { - if(await isSettingsShown(page)) return - await page.locator("button[data-l10n-id='pad.toolbar.settings.title']").click() - await page.waitForFunction(`document.querySelector('#settings').classList.contains('popup-show')`) -} - -export const hideSettings = async (page: Page) => { - if(!await isSettingsShown(page)) return - await page.locator("button[data-l10n-id='pad.toolbar.settings.title']").click() - await page.waitForFunction(`!document.querySelector('#settings').classList.contains('popup-show')`) -} - -export const enableStickyChatviaSettings = async (page: Page) => { - const stickyChat = page.locator('#options-stickychat') - const checked = await stickyChat.isChecked() - if(checked) return - await stickyChat.check({force: true}) - await page.waitForSelector('#options-stickychat:checked') -} - -export const disableStickyChat = async (page: Page) => { - const stickyChat = page.locator('#options-stickychat') - const checked = await stickyChat.isChecked() - if(!checked) return - await stickyChat.uncheck({force: true}) - await page.waitForSelector('#options-stickychat:not(:checked)') -} diff --git a/src/tests/frontend-new/helper/timeslider.ts b/src/tests/frontend-new/helper/timeslider.ts deleted file mode 100644 index e193048e0..000000000 --- a/src/tests/frontend-new/helper/timeslider.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {Page} from "@playwright/test"; - -/** - * Sets the src-attribute of the main iframe to the timeslider - * In case a revision is given, sets the timeslider to this specific revision. - * Defaults to going to the last revision. - * It waits until the timer is filled with date and time, because it's one of the - * last things that happen during timeslider load - * - * @param page - * @param {number} [revision] the optional revision - * @returns {Promise} - * @todo for some reason this does only work the first time, you cannot - * goto rev 0 and then via the same method to rev 5. Use buttons instead - */ -export const gotoTimeslider = async (page: Page, revision: number): Promise => { - let revisionString = Number.isInteger(revision) ? `#${revision}` : ''; - await page.goto(`${page.url()}/timeslider${revisionString}`); - await page.waitForSelector('#timer') -}; diff --git a/src/tests/frontend-new/specs/alphabet.spec.ts b/src/tests/frontend-new/specs/alphabet.spec.ts deleted file mode 100644 index fcd8f7f9d..000000000 --- a/src/tests/frontend-new/specs/alphabet.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {expect, Page, test} from "@playwright/test"; -import {clearPadContent, getPadBody, getPadOuter, goToNewPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - -test.describe('All the alphabet works n stuff', () => { - const expectedString = 'abcdefghijklmnopqrstuvwxyz'; - - test('when you enter any char it appears right', async ({page}) => { - - // get the inner iframe - const innerFrame = await getPadBody(page!); - - await innerFrame.click(); - - // delete possible old content - await clearPadContent(page!); - - - await page.keyboard.type(expectedString); - const text = await innerFrame.locator('div').innerText(); - expect(text).toBe(expectedString); - }); -}); diff --git a/src/tests/frontend-new/specs/bold.spec.ts b/src/tests/frontend-new/specs/bold.spec.ts deleted file mode 100644 index 6c1769da2..000000000 --- a/src/tests/frontend-new/specs/bold.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {randomInt} from "node:crypto"; -import {getPadBody, goToNewPad, selectAllText} from "../helper/padHelper"; -import exp from "node:constants"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('bold button', ()=>{ - - test('makes text bold on click', async ({page}) => { -// get the inner iframe - const innerFrame = await getPadBody(page); - - await innerFrame.click() - // Select pad text - await selectAllText(page); - await page.keyboard.type("Hi Etherpad"); - await selectAllText(page); - - // click the bold button - await page.locator("button[data-l10n-id='pad.toolbar.bold.title']").click(); - - - // check if the text is bold - expect(await innerFrame.locator('b').innerText()).toBe('Hi Etherpad'); - }) - - test('makes text bold on keypress', async ({page}) => { - // get the inner iframe - const innerFrame = await getPadBody(page); - - await innerFrame.click() - // Select pad text - await selectAllText(page); - await page.keyboard.type("Hi Etherpad"); - await selectAllText(page); - - // Press CTRL + B - await page.keyboard.down('Control'); - await page.keyboard.press('b'); - await page.keyboard.up('Control'); - - - // check if the text is bold - expect(await innerFrame.locator('b').innerText()).toBe('Hi Etherpad'); - }) - -}) diff --git a/src/tests/frontend-new/specs/change_user_color.spec.ts b/src/tests/frontend-new/specs/change_user_color.spec.ts deleted file mode 100644 index bc6b609a1..000000000 --- a/src/tests/frontend-new/specs/change_user_color.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {goToNewPad, sendChatMessage, showChat} from "../helper/padHelper"; - -test.beforeEach(async ({page}) => { - await goToNewPad(page); -}) - -test.describe('change user color', function () { - - test('Color picker matches original color and remembers the user color after a refresh', - async function ({page}) { - - // click on the settings button to make settings visible - let $userButton = page.locator('.buttonicon-showusers'); - await $userButton.click() - - let $userSwatch = page.locator('#myswatch'); - await $userSwatch.click() - // Change the color value of the Farbtastic color picker - - const $colorPickerSave = page.locator('#mycolorpickersave'); - let $colorPickerPreview = page.locator('#mycolorpickerpreview'); - - // Same color represented in two different ways - const testColorHash = '#abcdef'; - const testColorRGB = 'rgb(171, 205, 239)'; - - // Check that the color picker matches the automatically assigned random color on the swatch. - // NOTE: This has a tiny chance of creating a false positive for passing in the - // off-chance the randomly assigned color is the same as the test color. - expect(await $colorPickerPreview.getAttribute('style')).toContain(await $userSwatch.getAttribute('style')); - - // The swatch updates as the test color is picked. - await page.evaluate((testRGBColor) => { - document.getElementById('mycolorpickerpreview')!.style.backgroundColor = testRGBColor; - }, testColorRGB - ) - - await $colorPickerSave.click(); - - // give it a second to save the color on the server side - await page.waitForTimeout(1000) - - - // get a new pad, but don't clear the cookies - await goToNewPad(page) - - - // click on the settings button to make settings visible - await $userButton.click() - - await $userSwatch.click() - - - - expect(await $colorPickerPreview.getAttribute('style')).toContain(await $userSwatch.getAttribute('style')); - }); - - test('Own user color is shown when you enter a chat', async function ({page}) { - - const colorOption = page.locator('#options-colorscheck'); - if (!(await colorOption.isChecked())) { - await colorOption.check(); - } - - // click on the settings button to make settings visible - const $userButton = page.locator('.buttonicon-showusers'); - await $userButton.click() - - const $userSwatch = page.locator('#myswatch'); - await $userSwatch.click() - - const $colorPickerSave = page.locator('#mycolorpickersave'); - - // Same color represented in two different ways - const testColorHash = '#abcdef'; - const testColorRGB = 'rgb(171, 205, 239)'; - - // The swatch updates as the test color is picked. - await page.evaluate((testRGBColor) => { - document.getElementById('mycolorpickerpreview')!.style.backgroundColor = testRGBColor; - }, testColorRGB - ) - - - await $colorPickerSave.click(); - // click on the chat button to make chat visible - await showChat(page) - await sendChatMessage(page, 'O hi'); - - // wait until the chat message shows up - const chatP = page.locator('#chattext').locator('p') - const chatText = await chatP.innerText(); - - expect(chatText).toContain('O hi'); - - const color = await chatP.evaluate((el) => { - return window.getComputedStyle(el).getPropertyValue('background-color'); - }, chatText); - - expect(color).toBe(testColorRGB); - }); -}); diff --git a/src/tests/frontend-new/specs/change_user_name.spec.ts b/src/tests/frontend-new/specs/change_user_name.spec.ts deleted file mode 100644 index bf7ea95c3..000000000 --- a/src/tests/frontend-new/specs/change_user_name.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {randomInt} from "node:crypto"; -import {goToNewPad, sendChatMessage, setUserName, showChat, toggleUserList} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - - -test("Remembers the username after a refresh", async ({page}) => { - await toggleUserList(page); - await setUserName(page,'😃') - await toggleUserList(page) - - await page.reload(); - await toggleUserList(page); - const usernameField = page.locator("input[data-l10n-id='pad.userlist.entername']"); - await expect(usernameField).toHaveValue('😃'); -}) - - -test('Own user name is shown when you enter a chat', async ({page})=> { - const chatMessage = 'O hi'; - - await toggleUserList(page); - await setUserName(page,'😃'); - await toggleUserList(page); - - await showChat(page); - await sendChatMessage(page,chatMessage); - const chatText = await page.locator('#chattext').locator('p').innerText(); - expect(chatText).toContain('😃') - expect(chatText).toContain(chatMessage) -}); diff --git a/src/tests/frontend-new/specs/chat.spec.ts b/src/tests/frontend-new/specs/chat.spec.ts deleted file mode 100644 index 4d4f1bd1c..000000000 --- a/src/tests/frontend-new/specs/chat.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {randomInt} from "node:crypto"; -import { - appendQueryParams, - disableStickyChatviaIcon, - enableStickyChatviaIcon, - getChatMessage, - getChatTime, - getChatUserName, - getCurrentChatMessageCount, goToNewPad, hideChat, isChatBoxShown, isChatBoxSticky, - sendChatMessage, - showChat, -} from "../helper/padHelper"; -import {disableStickyChat, enableStickyChatviaSettings, hideSettings, showSettings} from "../helper/settingsHelper"; - - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - - -test('opens chat, sends a message, makes sure it exists on the page and hides chat', async ({page}) => { - const chatValue = "JohnMcLear" - - // Open chat - await showChat(page); - await sendChatMessage(page, chatValue); - - expect(await getCurrentChatMessageCount(page)).toBe(1); - const username = await getChatUserName(page) - const time = await getChatTime(page) - const chatMessage = await getChatMessage(page) - - expect(username).toBe('unnamed:'); - const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'); - expect(time).toMatch(regex); - expect(chatMessage).toBe(" "+chatValue); -}) - -test("makes sure that an empty message can't be sent", async function ({page}) { - const chatValue = 'mluto'; - - await showChat(page); - - await sendChatMessage(page,""); - // Send a message - await sendChatMessage(page,chatValue); - - expect(await getCurrentChatMessageCount(page)).toBe(1); - - // check that the received message is not the empty one - const username = await getChatUserName(page) - const time = await getChatTime(page); - const chatMessage = await getChatMessage(page); - - expect(username).toBe('unnamed:'); - const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'); - expect(time).toMatch(regex); - expect(chatMessage).toBe(" "+chatValue); -}); - -test('makes chat stick to right side of the screen via settings, remove sticky via settings, close it', async ({page}) =>{ - await showSettings(page); - - await enableStickyChatviaSettings(page); - expect(await isChatBoxShown(page)).toBe(true); - expect(await isChatBoxSticky(page)).toBe(true); - - await disableStickyChat(page); - expect(await isChatBoxShown(page)).toBe(true); - expect(await isChatBoxSticky(page)).toBe(false); - await hideSettings(page); - await hideChat(page); - expect(await isChatBoxShown(page)).toBe(false); - expect(await isChatBoxSticky(page)).toBe(false); -}); - -test('makes chat stick to right side of the screen via icon on the top right, ' + - 'remove sticky via icon, close it', async function ({page}) { - await showChat(page); - - await enableStickyChatviaIcon(page); - expect(await isChatBoxShown(page)).toBe(true); - expect(await isChatBoxSticky(page)).toBe(true); - - await disableStickyChatviaIcon(page); - expect(await isChatBoxShown(page)).toBe(true); - expect(await isChatBoxSticky(page)).toBe(false); - - await hideChat(page); - expect(await isChatBoxSticky(page)).toBe(false); - expect(await isChatBoxShown(page)).toBe(false); -}); - - -test('Checks showChat=false URL Parameter hides chat then' + - ' when removed it shows chat', async function ({page}) { - - // get a new pad, but don't clear the cookies - await appendQueryParams(page, { - showChat: 'false' - }); - - const chaticon = page.locator('#chaticon') - - - // chat should be hidden. - expect(await chaticon.isVisible()).toBe(false); - - // get a new pad, but don't clear the cookies - await goToNewPad(page); - const secondChatIcon = page.locator('#chaticon') - - // chat should be visible. - expect(await secondChatIcon.isVisible()).toBe(true) -}); diff --git a/src/tests/frontend-new/specs/clear_authorship_color.spec.ts b/src/tests/frontend-new/specs/clear_authorship_color.spec.ts deleted file mode 100644 index 6a999a57e..000000000 --- a/src/tests/frontend-new/specs/clear_authorship_color.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import {expect, test} from "@playwright/test"; -import { - clearAuthorship, - clearPadContent, - getPadBody, - goToNewPad, pressUndoButton, - selectAllText, - undoChanges, - writeToPad -} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - -test('clear authorship color', async ({page}) => { - // get the inner iframe - const innerFrame = await getPadBody(page); - const padText = "Hello" - - // type some text - await clearPadContent(page); - await writeToPad(page, padText); - const retrievedClasses = await innerFrame.locator('div span').nth(0).getAttribute('class') - expect(retrievedClasses).toContain('author'); - - // select the text - await innerFrame.click() - await selectAllText(page); - - await clearAuthorship(page); - // does the first div include an author class? - const firstDivClass = await innerFrame.locator('div').nth(0).getAttribute('class'); - expect(firstDivClass).not.toContain('author'); - const classes = page.locator('div.disconnected') - expect(await classes.isVisible()).toBe(false) -}) - - -test("makes text clear authorship colors and checks it can't be undone", async function ({page}) { - const innnerPad = await getPadBody(page); - const padText = "Hello" - - // type some text - await clearPadContent(page); - await writeToPad(page, padText); - - // get the first text element out of the inner iframe - const firstDivClass = innnerPad.locator('div').nth(0) - const retrievedClasses = await innnerPad.locator('div span').nth(0).getAttribute('class') - expect(retrievedClasses).toContain('author'); - - - await firstDivClass.focus() - await clearAuthorship(page); - expect(await firstDivClass.getAttribute('class')).not.toContain('author'); - - await undoChanges(page); - const changedFirstDiv = innnerPad.locator('div').nth(0) - expect(await changedFirstDiv.getAttribute('class')).not.toContain('author'); - - - await pressUndoButton(page); - const secondChangedFirstDiv = innnerPad.locator('div').nth(0) - expect(await secondChangedFirstDiv.getAttribute('class')).not.toContain('author'); -}); - - -// Test for https://github.com/ether/etherpad-lite/issues/5128 -test('clears authorship when first line has line attributes', async function ({page}) { - // Make sure there is text with author info. The first line must have a line attribute. - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page); - await writeToPad(page,'Hello') - await page.locator('.buttonicon-insertunorderedlist').click(); - const retrievedClasses = await padBody.locator('div span').nth(0).getAttribute('class') - expect(retrievedClasses).toContain('author'); - await padBody.click() - await selectAllText(page); - await clearAuthorship(page); - const retrievedClasses2 = await padBody.locator('div span').nth(0).getAttribute('class') - expect(retrievedClasses2).not.toContain('author'); - - expect(await page.locator('[class*="author-"]').count()).toBe(0) -}); diff --git a/src/tests/frontend-new/specs/collab_client.spec.ts b/src/tests/frontend-new/specs/collab_client.spec.ts deleted file mode 100644 index 5cc9c1ec3..000000000 --- a/src/tests/frontend-new/specs/collab_client.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import {clearPadContent, getPadBody, goToNewPad, goToPad, writeToPad} from "../helper/padHelper"; -import {expect, Page, test} from "@playwright/test"; - -let padId = ""; - -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - padId = await goToNewPad(page); - const body = await getPadBody(page); - await body.click(); - await clearPadContent(page); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); -}) - -test.describe('Messages in the COLLABROOM', function () { - const user1Text = 'text created by user 1'; - const user2Text = 'text created by user 2'; - - const replaceLineText = async (lineNumber: number, newText: string, page: Page) => { - const body = await getPadBody(page) - - const div = body.locator('div').nth(lineNumber) - - // simulate key presses to delete content - await div.locator('span').selectText() // select all - await page.keyboard.press('Backspace') // clear the first line - await page.keyboard.type(newText) // insert the string - }; - - test('bug #4978 regression test', async function ({browser}) { - // The bug was triggered by receiving a change from another user while simultaneously composing - // a character and waiting for an acknowledgement of a previously sent change. - - // User 1 - const context1 = await browser.newContext(); - const page1 = await context1.newPage(); - await goToPad(page1, padId) - const body1 = await getPadBody(page1) - // Perform actions as User 1... - - // User 2 - const context2 = await browser.newContext(); - const page2 = await context2.newPage(); - await goToPad(page2, padId) - const body2 = await getPadBody(page1) - - await replaceLineText(0, user1Text,page1); - - const text = await body2.locator('div').nth(0).textContent() - const res = text === user1Text - expect(res).toBe(true) - - // User 1 starts a character composition. - - - await replaceLineText(1, user2Text, page2) - - await expect(body1.locator('div').nth(1)).toHaveText(user2Text) - - - // Users 1 and 2 make some more changes. - await replaceLineText(3, user2Text, page2); - - await expect(body1.locator('div').nth(3)).toHaveText(user2Text) - - await replaceLineText(2, user1Text, page1); - await expect(body2.locator('div').nth(2)).toHaveText(user1Text) - - // All changes should appear in both views. - const expectedLines = [ - user1Text, - user2Text, - user1Text, - user2Text, - ]; - - for (let i=0;i{ - // create a new pad before each test run - await goToNewPad(page); -}) - - -test('delete keystroke', async ({page}) => { - const padText = "Hello World this is a test" - const body = await getPadBody(page) - await body.click() - await clearPadContent(page) - await page.keyboard.type(padText) - // Navigate to the end of the text - await page.keyboard.press('End'); - // Delete the last character - await page.keyboard.press('Backspace'); - const text = await body.locator('div').innerText(); - expect(text).toBe(padText.slice(0, -1)); -}) diff --git a/src/tests/frontend-new/specs/embed_value.spec.ts b/src/tests/frontend-new/specs/embed_value.spec.ts deleted file mode 100644 index a65276cc9..000000000 --- a/src/tests/frontend-new/specs/embed_value.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -import {expect, Page, test} from "@playwright/test"; -import {goToNewPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - -test.describe('embed links', function () { - const objectify = function (str: string) { - const hash = {}; - const parts = str.split('&'); - for (let i = 0; i < parts.length; i++) { - const keyValue = parts[i].split('='); - // @ts-ignore - hash[keyValue[0]] = keyValue[1]; - } - return hash; - }; - - const checkiFrameCode = async function (embedCode: string, readonly: boolean, page: Page) { - // turn the code into an html element - - await page.setContent(embedCode, {waitUntil: 'load'}) - const locator = page.locator('body').locator('iframe').last() - - - // read and check the frame attributes - const width = await locator.getAttribute('width'); - const height = await locator.getAttribute('height'); - const name = await locator.getAttribute('name'); - expect(width).toBe('100%'); - expect(height).toBe('600'); - expect(name).toBe(readonly ? 'embed_readonly' : 'embed_readwrite'); - - // parse the url - const src = (await locator.getAttribute('src'))!; - const questionMark = src.indexOf('?'); - const url = src.substring(0, questionMark); - const paramsStr = src.substring(questionMark + 1); - const params = objectify(paramsStr); - - const expectedParams = { - showControls: 'true', - showChat: 'true', - showLineNumbers: 'true', - useMonospaceFont: 'false', - }; - - // check the url - if (readonly) { - expect(url.indexOf('r.') > 0).toBe(true); - } else { - expect(url).toBe(await page.evaluate(() => window.location.href)); - } - - // check if all parts of the url are like expected - expect(params).toEqual(expectedParams); - }; - - test.describe('read and write', function () { - test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); - }) - test('the share link is the actual pad url', async function ({page}) { - - const shareButton = page.locator('.buttonicon-embed') - // open share dropdown - await shareButton.click() - - // get the link of the share field + the actual pad url and compare them - const shareLink = await page.locator('#linkinput').inputValue() - const padURL = page.url(); - expect(shareLink).toBe(padURL); - }); - - test('is an iframe with the correct url parameters and correct size', async function ({page}) { - - const shareButton = page.locator('.buttonicon-embed') - await shareButton.click() - - // get the link of the share field + the actual pad url and compare them - const embedCode = await page.locator('#embedinput').inputValue() - - - await checkiFrameCode(embedCode, false, page); - }); - }); - - test.describe('when read only option is set', function () { - test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); - }) - - test('the share link shows a read only url', async function ({page}) { - - // open share dropdown - const shareButton = page.locator('.buttonicon-embed') - await shareButton.click() - const readonlyCheckbox = page.locator('#readonlyinput') - await readonlyCheckbox.click({ - force: true - }) - await page.waitForSelector('#readonlyinput:checked') - - // get the link of the share field + the actual pad url and compare them - const shareLink = await page.locator('#linkinput').inputValue() - const containsReadOnlyLink = shareLink.indexOf('r.') > 0; - expect(containsReadOnlyLink).toBe(true); - }); - - test('the embed as iframe code is an iframe with the correct url parameters and correct size', async function ({page}) { - - - // open share dropdown - const shareButton = page.locator('.buttonicon-embed') - await shareButton.click() - - // check read only checkbox, a bit hacky - const readonlyCheckbox = page.locator('#readonlyinput') - await readonlyCheckbox.click({ - force: true - }) - - await page.waitForSelector('#readonlyinput:checked') - - - // get the link of the share field + the actual pad url and compare them - const embedCode = await page.locator('#embedinput').inputValue() - - await checkiFrameCode(embedCode, true, page); - }); - }) -}) diff --git a/src/tests/frontend-new/specs/enter.spec.ts b/src/tests/frontend-new/specs/enter.spec.ts deleted file mode 100644 index fd9c732c2..000000000 --- a/src/tests/frontend-new/specs/enter.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -'use strict'; -import {expect, test} from "@playwright/test"; -import {getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('enter keystroke', function () { - - test('creates a new line & puts cursor onto a new line', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const firstTextElement = padBody.locator('div').nth(0) - - // get the original string value minus the last char - const originalTextValue = await firstTextElement.textContent(); - - // simulate key presses to enter content - await firstTextElement.click() - await page.keyboard.press('Home'); - await page.keyboard.press('Enter'); - - const updatedFirstElement = padBody.locator('div').nth(0) - expect(await updatedFirstElement.textContent()).toBe('') - - const newSecondLine = padBody.locator('div').nth(1); - // expect the second line to be the same as the original first line. - expect(await newSecondLine.textContent()).toBe(originalTextValue); - }); - - test('enter is always visible after event', async function ({page}) { - const padBody = await getPadBody(page); - const originalLength = await padBody.locator('div').count(); - let lastLine = padBody.locator('div').last(); - - // simulate key presses to enter content - let i = 0; - const numberOfLines = 15; - while (i < numberOfLines) { - lastLine = padBody.locator('div').last(); - await lastLine.focus(); - await page.keyboard.press('End'); - await page.keyboard.press('Enter'); - - // check we can see the caret.. - i++; - } - - expect(await padBody.locator('div').count()).toBe(numberOfLines + originalLength); - - // is edited line fully visible? - const lastDiv = padBody.locator('div').last() - const lastDivOffset = await lastDiv.boundingBox(); - const bottomOfLastLine = lastDivOffset!.y + lastDivOffset!.height; - const scrolledWindow = page.frames()[0]; - const windowOffset = await scrolledWindow.evaluate(() => window.pageYOffset); - const windowHeight = await scrolledWindow.evaluate(() => window.innerHeight); - - expect(windowOffset + windowHeight).toBeGreaterThan(bottomOfLastLine); - }); -}); diff --git a/src/tests/frontend-new/specs/font_type.spec.ts b/src/tests/frontend-new/specs/font_type.spec.ts deleted file mode 100644 index a2772da99..000000000 --- a/src/tests/frontend-new/specs/font_type.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {getPadBody, goToNewPad} from "../helper/padHelper"; -import {showSettings} from "../helper/settingsHelper"; - -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - - -test.describe('font select', function () { - // create a new pad before each test run - - test('makes text RobotoMono', async function ({page}) { - // click on the settings button to make settings visible - await showSettings(page); - - // get the font menu and RobotoMono option - const viewFontMenu = page.locator('#viewfontmenu'); - - // select RobotoMono and fire change event - // $RobotoMonooption.attr('selected','selected'); - // commenting out above will break safari test - const dropdown = page.locator('.dropdowns-container .dropdown-line .current').nth(0) - await dropdown.click() - await page.locator('li:text("RobotoMono")').click() - - await viewFontMenu.dispatchEvent('change'); - const padBody = await getPadBody(page) - const color = await padBody.evaluate((e) => { - return window.getComputedStyle(e).getPropertyValue("font-family") - }) - - - // check if font changed to RobotoMono - const containsStr = color.toLowerCase().indexOf('robotomono'); - expect(containsStr).not.toBe(-1); - }); -}); diff --git a/src/tests/frontend-new/specs/indentation.spec.ts b/src/tests/frontend-new/specs/indentation.spec.ts deleted file mode 100644 index 3e94dbad3..000000000 --- a/src/tests/frontend-new/specs/indentation.spec.ts +++ /dev/null @@ -1,241 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('indentation button', function () { - test('indent text with keypress', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText() - - await page.keyboard.press('Tab'); - - const uls = padBody.locator('div').first().locator('ul li') - await expect(uls).toHaveCount(1); - }); - - test('indent text with button', async function ({page}) { - const padBody = await getPadBody(page); - await page.locator('.buttonicon-indent').click() - - const uls = padBody.locator('div').first().locator('ul') - await expect(uls).toHaveCount(1); - }); - - - test('keeps the indent on enter for the new line', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - await page.locator('.buttonicon-indent').click() - - // type a bit, make a line break and type again - await padBody.locator('div').first().focus() - await page.keyboard.type('line 1') - await page.keyboard.press('Enter'); - await page.keyboard.type('line 2') - await page.keyboard.press('Enter'); - - const $newSecondLine = padBody.locator('div span').nth(1) - - const hasULElement = padBody.locator('ul li') - - await expect(hasULElement).toHaveCount(3); - await expect($newSecondLine).toHaveText('line 2'); - }); - - - test('indents text with spaces on enter if previous line ends ' + - "with ':', '[', '(', or '{'", async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - // type a bit, make a line break and type again - const $firstTextElement = padBody.locator('div').first(); - await writeToPad(page, "line with ':'"); - await page.keyboard.press('Enter'); - await writeToPad(page, "line with '['"); - await page.keyboard.press('Enter'); - await writeToPad(page, "line with '('"); - await page.keyboard.press('Enter'); - await writeToPad(page, "line with '{{}'"); - - await expect(padBody.locator('div').nth(3)).toHaveText("line with '{{}'"); - - // we validate bottom to top for easier implementation - - - // curly braces - const $lineWithCurlyBraces = padBody.locator('div').nth(3) - await $lineWithCurlyBraces.click(); - await page.keyboard.press('End'); - await page.keyboard.type('{{'); - - // cannot use sendkeys('{enter}') here, browser does not read the command properly - await page.keyboard.press('Enter'); - - expect(await padBody.locator('div').nth(4).textContent()).toMatch(/\s{4}/); // tab === 4 spaces - - - - // parenthesis - const $lineWithParenthesis = padBody.locator('div').nth(2) - await $lineWithParenthesis.click(); - await page.keyboard.press('End'); - await page.keyboard.type('('); - await page.keyboard.press('Enter'); - const $lineAfterParenthesis = padBody.locator('div').nth(3) - expect(await $lineAfterParenthesis.textContent()).toMatch(/\s{4}/); - - // bracket - const $lineWithBracket = padBody.locator('div').nth(1) - await $lineWithBracket.click(); - await page.keyboard.press('End'); - await page.keyboard.type('['); - await page.keyboard.press('Enter'); - const $lineAfterBracket = padBody.locator('div').nth(2); - expect(await $lineAfterBracket.textContent()).toMatch(/\s{4}/); - - // colon - const $lineWithColon = padBody.locator('div').first(); - await $lineWithColon.click(); - await page.keyboard.press('End'); - await page.keyboard.type(':'); - await page.keyboard.press('Enter'); - const $lineAfterColon = padBody.locator('div').nth(1); - expect(await $lineAfterColon.textContent()).toMatch(/\s{4}/); - }); - - test('appends indentation to the indent of previous line if previous line ends ' + - "with ':', '[', '(', or '{'", async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - // type a bit, make a line break and type again - await writeToPad(page, " line with some indentation and ':'") - await page.keyboard.press('Enter'); - await writeToPad(page, "line 2") - - const $lineWithColon = padBody.locator('div').first(); - await $lineWithColon.click(); - await page.keyboard.press('End'); - await page.keyboard.type(':'); - await page.keyboard.press('Enter'); - - const $lineAfterColon = padBody.locator('div').nth(1); - // previous line indentation + regular tab (4 spaces) - expect(await $lineAfterColon.textContent()).toMatch(/\s{6}/); - }); - - test("issue #2772 shows '*' when multiple indented lines " + - ' receive a style and are outdented', async function ({page}) { - - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - const inner = padBody.locator('div').first(); - // make sure pad has more than one line - await inner.click() - await page.keyboard.type('First'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Second'); - - - // indent first 2 lines - await padBody.locator('div').nth(0).selectText(); - await page.locator('.buttonicon-indent').click() - - await padBody.locator('div').nth(1).selectText(); - await page.locator('.buttonicon-indent').click() - - - await expect(padBody.locator('ul li')).toHaveCount(2); - - - // apply bold - await padBody.locator('div').nth(0).selectText(); - await page.locator('.buttonicon-bold').click() - - await padBody.locator('div').nth(1).selectText(); - await page.locator('.buttonicon-bold').click() - - await expect(padBody.locator('div b')).toHaveCount(2); - - // outdent first 2 lines - await padBody.locator('div').nth(0).selectText(); - await page.locator('.buttonicon-outdent').click() - - await padBody.locator('div').nth(1).selectText(); - await page.locator('.buttonicon-outdent').click() - - await expect(padBody.locator('ul li')).toHaveCount(0); - - // check if '*' is displayed - const secondLine = padBody.locator('div').nth(1); - await expect(secondLine).toHaveText('Second'); - }); - - test('makes text indented and outdented', async function ({page}) { - // get the inner iframe - - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - let firstTextElement = padBody.locator('div').first(); - - // select this text element - await firstTextElement.selectText() - - // get the indentation button and click it - await page.locator('.buttonicon-indent').click() - - let newFirstTextElement = padBody.locator('div').first(); - - // is there a list-indent class element now? - await expect(newFirstTextElement.locator('ul')).toHaveCount(1); - - await expect(newFirstTextElement.locator('li')).toHaveCount(1); - - // indent again - await page.locator('.buttonicon-indent').click() - - newFirstTextElement = padBody.locator('div').first(); - - - // is there a list-indent class element now? - const ulList = newFirstTextElement.locator('ul').first() - await expect(ulList).toHaveCount(1); - // expect it to be part of a list - expect(await ulList.getAttribute('class')).toBe('list-indent2'); - - // make sure the text hasn't changed - expect(await newFirstTextElement.textContent()).toBe(await firstTextElement.textContent()); - - - // test outdent - - // get the unindentation button and click it twice - newFirstTextElement = padBody.locator('div').first(); - await newFirstTextElement.selectText() - await page.locator('.buttonicon-outdent').click() - await page.locator('.buttonicon-outdent').click() - - newFirstTextElement = padBody.locator('div').first(); - - // is there a list-indent class element now? - await expect(newFirstTextElement.locator('ul')).toHaveCount(0); - - // make sure the text hasn't changed - expect(await newFirstTextElement.textContent()).toEqual(await firstTextElement.textContent()); - }); -}); diff --git a/src/tests/frontend-new/specs/inner_height.spec.ts b/src/tests/frontend-new/specs/inner_height.spec.ts deleted file mode 100644 index 3baa7e49b..000000000 --- a/src/tests/frontend-new/specs/inner_height.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('height regression after ace.js refactoring', function () { - - test('clientHeight should equal scrollHeight with few lines', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - const iframe = page.locator('iframe').first() - const scrollHeight = await iframe.evaluate((element) => { - return element.scrollHeight; - }) - - const clientHeight = await iframe.evaluate((element) => { - return element.clientHeight; - }) - - - expect(clientHeight).toEqual(scrollHeight); - }); - - test('client height should be less than scrollHeight with many lines', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - await writeToPad(page,'Test line\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n'); - - const iframe = page.locator('iframe').first() - const scrollHeight = await iframe.evaluate((element) => { - return element.scrollHeight; - }) - - const clientHeight = await iframe.evaluate((element) => { - return element.clientHeight; - }) - - // Need to poll because the heights take some time to settle. - expect(clientHeight).toBeLessThanOrEqual(scrollHeight); - }); -}); diff --git a/src/tests/frontend-new/specs/italic.spec.ts b/src/tests/frontend-new/specs/italic.spec.ts deleted file mode 100644 index dc69f0e38..000000000 --- a/src/tests/frontend-new/specs/italic.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('italic some text', function () { - - test('makes text italic using button', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - await $firstTextElement.click() - await writeToPad(page, 'Foo') - - // select this text element - await padBody.click() - await page.keyboard.press('Control+A'); - - // get the bold button and click it - const $boldButton = page.locator('.buttonicon-italic'); - await $boldButton.click(); - - // ace creates a new dom element when you press a button, just get the first text element again - const $newFirstTextElement = padBody.locator('div').first(); - - // is there a element now? - // expect it to be italic - await expect($newFirstTextElement.locator('i')).toHaveCount(1); - - - // make sure the text hasn't changed - expect(await $newFirstTextElement.textContent()).toEqual(await $firstTextElement.textContent()); - }); - - test('makes text italic using keypress', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await writeToPad(page, 'Foo') - - await page.keyboard.press('Control+A'); - - await page.keyboard.press('Control+I'); - - // ace creates a new dom element when you press a button, just get the first text element again - const $newFirstTextElement = padBody.locator('div').first(); - - // is there a element now? - // expect it to be italic - await expect($newFirstTextElement.locator('i')).toHaveCount(1); - - // make sure the text hasn't changed - expect(await $newFirstTextElement.textContent()).toBe(await $firstTextElement.textContent()); - }); -}); diff --git a/src/tests/frontend-new/specs/language.spec.ts b/src/tests/frontend-new/specs/language.spec.ts deleted file mode 100644 index 87da86b13..000000000 --- a/src/tests/frontend-new/specs/language.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {getPadBody, goToNewPad} from "../helper/padHelper"; -import {showSettings} from "../helper/settingsHelper"; - -test.beforeEach(async ({ page, browser })=>{ - const context = await browser.newContext() - await context.clearCookies() - await goToNewPad(page); -}) - - - -test.describe('Language select and change', function () { - - // Destroy language cookies - test('makes text german', async function ({page}) { - // click on the settings button to make settings visible - await showSettings(page) - - // click the language button - const languageDropDown = page.locator('.nice-select').nth(1) - - await languageDropDown.click() - await page.locator('.nice-select').locator('[data-value=de]').click() - await expect(languageDropDown.locator('.current')).toHaveText('Deutsch') - - // select german - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)'); - }); - - test('makes text English', async function ({page}) { - - await showSettings(page) - - // click the language button - await page.locator('.nice-select').nth(1).locator('.current').click() - await page.locator('.nice-select').locator('[data-value=de]').click() - - // select german - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)'); - - - // change to english - await page.locator('.nice-select').nth(1).locator('.current').click() - await page.locator('.nice-select').locator('[data-value=en]').click() - - // check if the language is now English - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title !== 'Fett (Strg-B)'); - }); - - test('changes direction when picking an rtl lang', async function ({page}) { - - await showSettings(page) - - // click the language button - await page.locator('.nice-select').nth(1).locator('.current').click() - await page.locator('.nice-select').locator('[data-value=de]').click() - - // select german - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)'); - - // click the language button - await page.locator('.nice-select').nth(1).locator('.current').click() - // select arabic - // $languageoption.attr('selected','selected'); // Breaks the test.. - await page.locator('.nice-select').locator('[data-value=ar]').click() - - await page.waitForSelector('html[dir="rtl"]') - }); - - test('changes direction when picking an ltr lang', async function ({page}) { - await showSettings(page) - - // change to english - const languageDropDown = page.locator('.nice-select').nth(1) - await languageDropDown.locator('.current').click() - await languageDropDown.locator('[data-value=en]').click() - - await expect(languageDropDown.locator('.current')).toHaveText('English') - - // check if the language is now English - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title !== 'Fett (Strg-B)'); - - - await page.waitForSelector('html[dir="ltr"]') - - }); -}); diff --git a/src/tests/frontend-new/specs/ordered_list.spec.ts b/src/tests/frontend-new/specs/ordered_list.spec.ts deleted file mode 100644 index 04e996e66..000000000 --- a/src/tests/frontend-new/specs/ordered_list.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - - -test.describe('ordered_list.js', function () { - - test('issue #4748 keeps numbers increment on OL', async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await writeToPad(page, 'Line 1') - await page.keyboard.press('Enter') - await writeToPad(page, 'Line 2') - - const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist') - await padBody.locator('div').first().selectText() - await $insertorderedlistButton.first().click(); - - const secondLine = padBody.locator('div').nth(1) - - await secondLine.selectText() - await $insertorderedlistButton.click(); - - expect(await secondLine.locator('ol').getAttribute('start')).toEqual('2'); - }); - - test('issue #1125 keeps the numbered list on enter for the new line', async function ({page}) { - // EMULATES PASTING INTO A PAD - const padBody = await getPadBody(page); - await clearPadContent(page) - await expect(padBody.locator('div')).toHaveCount(1) - const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist') - await $insertorderedlistButton.click(); - - // type a bit, make a line break and type again - const firstTextElement = padBody.locator('div').first() - await firstTextElement.click() - await writeToPad(page, 'line 1') - await page.keyboard.press('Enter') - await writeToPad(page, 'line 2') - await page.keyboard.press('Enter') - - await expect(padBody.locator('div span').nth(1)).toHaveText('line 2'); - - const $newSecondLine = padBody.locator('div').nth(1) - expect(await $newSecondLine.locator('ol li').count()).toEqual(1); - await expect($newSecondLine.locator('ol li').nth(0)).toHaveText('line 2'); - const hasLineNumber = await $newSecondLine.locator('ol').getAttribute('start'); - // This doesn't work because pasting in content doesn't work - expect(Number(hasLineNumber)).toBe(2); - }); - }); - - test.describe('Pressing Tab in an OL increases and decreases indentation', function () { - - test('indent and de-indent list item with keypress', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText() - - const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist') - await $insertorderedlistButton.click() - - await page.keyboard.press('Tab') - - await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1) - - await page.keyboard.press('Shift+Tab') - - - await expect(padBody.locator('div').first().locator('.list-number1')).toHaveCount(1) - }); - }); - - - test.describe('Pressing indent/outdent button in an OL increases and ' + - 'decreases indentation and bullet / ol formatting', function () { - - test('indent and de-indent list item with indent button', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText() - - const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist') - await $insertorderedlistButton.click() - - const $indentButton = page.locator('.buttonicon-indent') - await $indentButton.dblclick() // make it indented twice - - const outdentButton = page.locator('.buttonicon-outdent') - - await expect(padBody.locator('div').first().locator('.list-number3')).toHaveCount(1) - - await outdentButton.click(); // make it deindented to 1 - - await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1) - }); - }); diff --git a/src/tests/frontend-new/specs/redo.spec.ts b/src/tests/frontend-new/specs/redo.spec.ts deleted file mode 100644 index b3df70c69..000000000 --- a/src/tests/frontend-new/specs/redo.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - - -test.describe('undo button then redo button', function () { - - - test('redo some typing with button', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element inside the editable space - const $firstTextElement = padBody.locator('div span').first(); - const originalValue = await $firstTextElement.textContent(); // get the original value - const newString = 'Foo'; - - await $firstTextElement.focus() - expect(await $firstTextElement.textContent()).toContain(originalValue); - await padBody.click() - await clearPadContent(page) - await writeToPad(page, newString); // send line 1 to the pad - - const modifiedValue = await $firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change - - // get undo and redo buttons // click the buttons - await page.locator('.buttonicon-undo').click() // removes foo - await page.locator('.buttonicon-redo').click() // resends foo - - await expect($firstTextElement).toHaveText(newString); - - const finalValue = await padBody.locator('div').first().textContent(); - expect(finalValue).toBe(modifiedValue); // expect the value to change - }); - - test('redo some typing with keypress', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element inside the editable space - const $firstTextElement = padBody.locator('div span').first(); - const originalValue = await $firstTextElement.textContent(); // get the original value - const newString = 'Foo'; - - await padBody.click() - await clearPadContent(page) - await writeToPad(page, newString); // send line 1 to the pad - const modifiedValue = await $firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change - - // undo the change - await padBody.click() - await page.keyboard.press('Control+Z'); - - await page.keyboard.press('Control+Y'); // redo the change - - - await expect($firstTextElement).toHaveText(newString); - - const finalValue = await padBody.locator('div').first().textContent(); - expect(finalValue).toBe(modifiedValue); // expect the value to change - }); -}); diff --git a/src/tests/frontend-new/specs/strikethrough.spec.ts b/src/tests/frontend-new/specs/strikethrough.spec.ts deleted file mode 100644 index a4f68b4a7..000000000 --- a/src/tests/frontend-new/specs/strikethrough.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('strikethrough button', function () { - - test('makes text strikethrough', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText() - - // get the strikethrough button and click it - await page.locator('.buttonicon-strikethrough').click(); - - // ace creates a new dom element when you press a button, just get the first text element again - - // is there a element now? - await expect($firstTextElement.locator('s')).toHaveCount(1); - - // make sure the text hasn't changed - expect(await $firstTextElement.textContent()).toEqual(await $firstTextElement.textContent()); - }); -}); diff --git a/src/tests/frontend-new/specs/timeslider.spec.ts b/src/tests/frontend-new/specs/timeslider.spec.ts deleted file mode 100644 index 317398f18..000000000 --- a/src/tests/frontend-new/specs/timeslider.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - - -// deactivated, we need a nice way to get the timeslider, this is ugly -test.describe('timeslider button takes you to the timeslider of a pad', function () { - - test('timeslider contained in URL', async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await writeToPad(page, 'Foo'); // send line 1 to the pad - - // get the first text element inside the editable space - const $firstTextElement = padBody.locator('div span').first(); - const originalValue = await $firstTextElement.textContent(); // get the original value - await $firstTextElement.click() - await writeToPad(page, 'Testing'); // send line 1 to the pad - - const modifiedValue = await $firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change - - const $timesliderButton = page.locator('.buttonicon-history'); - await $timesliderButton.click(); // So click the timeslider link - - await page.waitForSelector('#timeslider-wrapper') - - const iFrameURL = page.url(); // get the url - const inTimeslider = iFrameURL.indexOf('timeslider') !== -1; - - expect(inTimeslider).toBe(true); // expect the value to change - }); -}); diff --git a/src/tests/frontend-new/specs/timeslider_follow.spec.ts b/src/tests/frontend-new/specs/timeslider_follow.spec.ts deleted file mode 100644 index 9f104b884..000000000 --- a/src/tests/frontend-new/specs/timeslider_follow.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -'use strict'; -import {expect, Page, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; -import {gotoTimeslider} from "../helper/timeslider"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - - -test.describe('timeslider follow', function () { - - // TODO needs test if content is also followed, when user a makes edits - // while user b is in the timeslider - test("content as it's added to timeslider", async function ({page}) { - // send 6 revisions - const revs = 6; - const message = 'a\n\n\n\n\n\n\n\n\n\n'; - const newLines = message.split('\n').length; - for (let i = 0; i < revs; i++) { - await writeToPad(page, message) - } - - await gotoTimeslider(page,0); - expect(page.url()).toContain('#0'); - - const originalTop = await page.evaluate(() => { - return window.document.querySelector('#innerdocbody')!.getBoundingClientRect().top; - }); - - // set to follow contents as it arrives - await page.check('#options-followContents'); - await page.click('#playpause_button_icon'); - - // wait for the scroll - await page.waitForTimeout(1000) - - const currentOffset = await page.evaluate(() => { - return window.document.querySelector('#innerdocbody')!.getBoundingClientRect().top; - }); - - expect(currentOffset).toBeLessThanOrEqual(originalTop); - }); - - /** - * Tests for bug described in #4389 - * The goal is to scroll to the first line that contains a change right before - * the change is applied. - */ - test('only to lines that exist in the pad view, regression test for #4389', async function ({page}) { - const padBody = await getPadBody(page) - await padBody.click() - - await clearPadContent(page) - - await writeToPad(page,'Test line\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n'); - await padBody.locator('div').nth(40).click(); - await writeToPad(page, 'Another test line'); - - - await gotoTimeslider(page, 200); - - // set to follow contents as it arrives - await page.check('#options-followContents'); - - await page.waitForTimeout(1000) - - const oldYPosition = await page.locator('#editorcontainerbox').evaluate((el) => { - return el.scrollTop; - }) - expect(oldYPosition).toBe(0); - }); -}); diff --git a/src/tests/frontend-new/specs/undo.spec.ts b/src/tests/frontend-new/specs/undo.spec.ts deleted file mode 100644 index cdbc12083..000000000 --- a/src/tests/frontend-new/specs/undo.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - - -test.describe('undo button', function () { - - test('undo some typing by clicking undo button', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - - // get the first text element inside the editable space - const firstTextElement = padBody.locator('div').first() - const originalValue = await firstTextElement.textContent(); // get the original value - await firstTextElement.focus() - - await writeToPad(page, 'foo'); // send line 1 to the pad - - const modifiedValue = await firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change - - // get clear authorship button as a variable - const undoButton = page.locator('.buttonicon-undo') - await undoButton.click() // click the button - - await expect(firstTextElement).toHaveText(originalValue!); - }); - - test('undo some typing using a keypress', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - // get the first text element inside the editable space - const firstTextElement = padBody.locator('div').first() - const originalValue = await firstTextElement.textContent(); // get the original value - - await firstTextElement.focus() - await writeToPad(page, 'foo'); // send line 1 to the pad - const modifiedValue = await firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change - - // undo the change - await page.keyboard.press('Control+Z'); - await page.waitForTimeout(1000) - - await expect(firstTextElement).toHaveText(originalValue!); - }); -}); diff --git a/src/tests/frontend-new/specs/unordered_list.spec.ts b/src/tests/frontend-new/specs/unordered_list.spec.ts deleted file mode 100644 index a2465e5af..000000000 --- a/src/tests/frontend-new/specs/unordered_list.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - -test.describe('unordered_list.js', function () { - test.describe('assign unordered list', function () { - test('insert unordered list text then removes by outdent', async function ({page}) { - const padBody = await getPadBody(page); - const originalText = await padBody.locator('div').first().textContent(); - - const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist'); - await $insertunorderedlistButton.click(); - - await expect(padBody.locator('div').first()).toHaveText(originalText!); - await expect(padBody.locator('div ul li')).toHaveCount(1); - - // remove indentation by bullet and ensure text string remains the same - const $outdentButton = page.locator('.buttonicon-outdent'); - await $outdentButton.click(); - await expect(padBody.locator('div').first()).toHaveText(originalText!); - }); - }); - - test.describe('unassign unordered list', function () { - // create a new pad before each test run - - - test('insert unordered list text then remove by clicking list again', async function ({page}) { - const padBody = await getPadBody(page); - const originalText = await padBody.locator('div').first().textContent(); - - await padBody.locator('div').first().selectText() - const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist'); - await $insertunorderedlistButton.click(); - - await expect(padBody.locator('div').first()).toHaveText(originalText!); - await expect(padBody.locator('div ul li')).toHaveCount(1); - - // remove indentation by bullet and ensure text string remains the same - await $insertunorderedlistButton.click(); - await expect(padBody.locator('div').locator('ul')).toHaveCount(0) - }); - }); - - - test.describe('keep unordered list on enter key', function () { - - test('Keeps the unordered list on enter for the new line', async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await expect(padBody.locator('div')).toHaveCount(1) - - const $insertorderedlistButton = page.locator('.buttonicon-insertunorderedlist') - await $insertorderedlistButton.click(); - - // type a bit, make a line break and type again - const $firstTextElement = padBody.locator('div').first(); - await $firstTextElement.click() - await page.keyboard.type('line 1'); - await page.keyboard.press('Enter'); - await page.keyboard.type('line 2'); - await page.keyboard.press('Enter'); - - await expect(padBody.locator('div span')).toHaveCount(2); - - - const $newSecondLine = padBody.locator('div').nth(1) - await expect($newSecondLine.locator('ul')).toHaveCount(1); - await expect($newSecondLine).toHaveText('line 2'); - }); - }); - - test.describe('Pressing Tab in an UL increases and decreases indentation', function () { - - test('indent and de-indent list item with keypress', async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText(); - - const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist'); - await $insertunorderedlistButton.click(); - - await padBody.locator('div').first().click(); - await page.keyboard.press('Home'); - await page.keyboard.press('Tab'); - await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1); - - await page.keyboard.press('Shift+Tab'); - - await expect(padBody.locator('div').first().locator('.list-bullet1')).toHaveCount(1); - }); - }); - - test.describe('Pressing indent/outdent button in an UL increases and decreases indentation ' + - 'and bullet / ol formatting', function () { - - test('indent and de-indent list item with indent button', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText(); - - const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist'); - await $insertunorderedlistButton.click(); - - await page.locator('.buttonicon-indent').click(); - - await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1); - const outdentButton = page.locator('.buttonicon-outdent'); - await outdentButton.click(); - - await expect(padBody.locator('div').first().locator('.list-bullet1')).toHaveCount(1); - }); - }); -}); diff --git a/src/tests/frontend-new/specs/urls_become_clickable.spec.ts b/src/tests/frontend-new/specs/urls_become_clickable.spec.ts deleted file mode 100644 index 0397502bc..000000000 --- a/src/tests/frontend-new/specs/urls_become_clickable.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('entering a URL makes a link', function () { - for (const url of ['https://etherpad.org', 'www.etherpad.org', 'https://www.etherpad.org']) { - test(url, async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - const url = 'https://etherpad.org'; - await writeToPad(page, url); - await expect(padBody.locator('div').first()).toHaveText(url); - await expect(padBody.locator('a')).toHaveText(url); - await expect(padBody.locator('a')).toHaveAttribute('href', url); - }); - } -}); - - -test.describe('special characters inside URL', async function () { - for (const char of '-:@_.,~%+/?=&#!;()[]$\'*') { - const url = `https://etherpad.org/${char}foo`; - test(url, async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await padBody.click() - await clearPadContent(page) - await writeToPad(page, url); - await expect(padBody.locator('div').first()).toHaveText(url); - await expect(padBody.locator('a')).toHaveText(url); - await expect(padBody.locator('a')).toHaveAttribute('href', url); - }); - } -}); - -test.describe('punctuation after URL is ignored', ()=> { - for (const char of ':.,;?!)]\'*') { - const want = 'https://etherpad.org'; - const input = want + char; - test(input, async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await writeToPad(page, input); - await expect(padBody.locator('a')).toHaveCount(1); - await expect(padBody.locator('a')).toHaveAttribute('href', want); - }); - } -}); diff --git a/src/tests/frontend/cypress/.gitignore b/src/tests/frontend/cypress/.gitignore deleted file mode 100644 index b3bf4d3e2..000000000 --- a/src/tests/frontend/cypress/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -fixtures/* -plugins/* -support/* -videos/* -screenshots/* diff --git a/src/tests/frontend/cypress/README.md b/src/tests/frontend/cypress/README.md deleted file mode 100644 index 4cc3f8121..000000000 --- a/src/tests/frontend/cypress/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Cypress Etherpad guide -We don't install Etherpad as a dev dep or dep within Etherpad because it's not -our core Frontend testing tool - -## Quick start -``` -npm i -g cypress -cd src/tests/frontend/cypress/ -cypress open -``` diff --git a/src/tests/frontend/cypress/cypress.config.js b/src/tests/frontend/cypress/cypress.config.js deleted file mode 100644 index 3754350de..000000000 --- a/src/tests/frontend/cypress/cypress.config.js +++ /dev/null @@ -1,9 +0,0 @@ -const { defineConfig } = require('cypress') - -module.exports = defineConfig({ - e2e: { - baseUrl: "http://127.0.0.1:9001", - supportFile: false, - specPattern: 'tests/frontend/cypress/integration/**/*.js' - } -}) diff --git a/src/tests/frontend/cypress/integration/test.js b/src/tests/frontend/cypress/integration/test.js deleted file mode 100644 index 893d4b669..000000000 --- a/src/tests/frontend/cypress/integration/test.js +++ /dev/null @@ -1,23 +0,0 @@ -'use strict'; - -Cypress.Commands.add('iframe', {prevSubject: 'element'}, - ($iframe) => new Cypress.Promise((resolve) => { - $iframe.ready(() => { - resolve($iframe.contents().find('body')); - }); - })); - -describe(__filename, () => { - it('Pad content exists', () => { - cy.visit('http://127.0.0.1:9001/p/test'); - cy.wait(10000); // wait for Minified JS to be built... - cy.get('iframe[name="ace_outer"]', {timeout: 10000}).iframe() - .find('.line-number:first') - .should('have.text', '1'); - cy.get('iframe[name="ace_outer"]').iframe() - .find('iframe[name="ace_inner"]').iframe() - .find('.ace-line:first') - .should('be.visible') - .should('have.text', 'Welcome to Etherpad!'); - }); -}); diff --git a/src/tests/frontend/easysync-helper.js b/src/tests/frontend/easysync-helper.js deleted file mode 100644 index b4f770963..000000000 --- a/src/tests/frontend/easysync-helper.js +++ /dev/null @@ -1,222 +0,0 @@ -'use strict'; - -const Changeset = require('../../static/js/Changeset'); -const AttributePool = require('../../static/js/AttributePool'); - -const randInt = (maxValue) => Math.floor(Math.random() * maxValue); - -const poolOrArray = (attribs) => { - if (attribs.getAttrib) { - return attribs; // it's already an attrib pool - } else { - // assume it's an array of attrib strings to be split and added - const p = new AttributePool(); - attribs.forEach((kv) => { - p.putAttrib(kv.split(',')); - }); - return p; - } -}; -exports.poolOrArray = poolOrArray; - -const randomInlineString = (len) => { - const assem = Changeset.stringAssembler(); - for (let i = 0; i < len; i++) { - assem.append(String.fromCharCode(randInt(26) + 97)); - } - return assem.toString(); -}; - -const randomMultiline = (approxMaxLines, approxMaxCols) => { - const numParts = randInt(approxMaxLines * 2) + 1; - const txt = Changeset.stringAssembler(); - txt.append(randInt(2) ? '\n' : ''); - for (let i = 0; i < numParts; i++) { - if ((i % 2) === 0) { - if (randInt(10)) { - txt.append(randomInlineString(randInt(approxMaxCols) + 1)); - } else { - txt.append('\n'); - } - } else { - txt.append('\n'); - } - } - return txt.toString(); -}; -exports.randomMultiline = randomMultiline; - -const randomStringOperation = (numCharsLeft) => { - let result; - switch (randInt(11)) { - case 0: - { - // insert char - result = { - insert: randomInlineString(1), - }; - break; - } - case 1: - { - // delete char - result = { - remove: 1, - }; - break; - } - case 2: - { - // skip char - result = { - skip: 1, - }; - break; - } - case 3: - { - // insert small - result = { - insert: randomInlineString(randInt(4) + 1), - }; - break; - } - case 4: - { - // delete small - result = { - remove: randInt(4) + 1, - }; - break; - } - case 5: - { - // skip small - result = { - skip: randInt(4) + 1, - }; - break; - } - case 6: - { - // insert multiline; - result = { - insert: randomMultiline(5, 20), - }; - break; - } - case 7: - { - // delete multiline - result = { - remove: Math.round(numCharsLeft * Math.random() * Math.random()), - }; - break; - } - case 8: - { - // skip multiline - result = { - skip: Math.round(numCharsLeft * Math.random() * Math.random()), - }; - break; - } - case 9: - { - // delete to end - result = { - remove: numCharsLeft, - }; - break; - } - case 10: - { - // skip to end - result = { - skip: numCharsLeft, - }; - break; - } - } - const maxOrig = numCharsLeft - 1; - if ('remove' in result) { - result.remove = Math.min(result.remove, maxOrig); - } else if ('skip' in result) { - result.skip = Math.min(result.skip, maxOrig); - } - return result; -}; - -const randomTwoPropAttribs = (opcode) => { - // assumes attrib pool like ['apple,','apple,true','banana,','banana,true'] - if (opcode === '-' || randInt(3)) { - return ''; - } else if (randInt(3)) { // eslint-disable-line no-dupe-else-if - if (opcode === '+' || randInt(2)) { - return `*${Changeset.numToString(randInt(2) * 2 + 1)}`; - } else { - return `*${Changeset.numToString(randInt(2) * 2)}`; - } - } else if (opcode === '+' || randInt(4) === 0) { - return '*1*3'; - } else { - return ['*0*2', '*0*3', '*1*2'][randInt(3)]; - } -}; - -const randomTestChangeset = (origText, withAttribs) => { - const charBank = Changeset.stringAssembler(); - let textLeft = origText; // always keep final newline - const outTextAssem = Changeset.stringAssembler(); - const opAssem = Changeset.smartOpAssembler(); - const oldLen = origText.length; - - const nextOp = new Changeset.Op(); - - const appendMultilineOp = (opcode, txt) => { - nextOp.opcode = opcode; - if (withAttribs) { - nextOp.attribs = randomTwoPropAttribs(opcode); - } - txt.replace(/\n|[^\n]+/g, (t) => { - if (t === '\n') { - nextOp.chars = 1; - nextOp.lines = 1; - opAssem.append(nextOp); - } else { - nextOp.chars = t.length; - nextOp.lines = 0; - opAssem.append(nextOp); - } - return ''; - }); - }; - - const doOp = () => { - const o = randomStringOperation(textLeft.length); - if (o.insert) { - const txt = o.insert; - charBank.append(txt); - outTextAssem.append(txt); - appendMultilineOp('+', txt); - } else if (o.skip) { - const txt = textLeft.substring(0, o.skip); - textLeft = textLeft.substring(o.skip); - outTextAssem.append(txt); - appendMultilineOp('=', txt); - } else if (o.remove) { - const txt = textLeft.substring(0, o.remove); - textLeft = textLeft.substring(o.remove); - appendMultilineOp('-', txt); - } - }; - - while (textLeft.length > 1) doOp(); - for (let i = 0; i < 5; i++) doOp(); // do some more (only insertions will happen) - const outText = `${outTextAssem.toString()}\n`; - opAssem.endDocument(); - const cs = Changeset.pack(oldLen, outText.length, opAssem.toString(), charBank.toString()); - Changeset.checkRep(cs); - return [cs, outText]; -}; -exports.randomTestChangeset = randomTestChangeset; diff --git a/src/tests/frontend/helper.js b/src/tests/frontend/helper.js index 18981897e..4dd7f22ac 100644 --- a/src/tests/frontend/helper.js +++ b/src/tests/frontend/helper.js @@ -4,6 +4,20 @@ const helper = {}; (() => { let $iframe; + const jsLibraries = {}; + + helper.init = (cb) => { + $.get('/static/js/jquery.js').done((code) => { + // make sure we don't override existing jquery + jsLibraries.jquery = `if(typeof $ === 'undefined') {\n${code}\n}`; + + $.get('/tests/frontend/lib/sendkeys.js').done((code) => { + jsLibraries.sendkeys = code; + + cb(); + }); + }); + }; helper.randomString = (len) => { const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; @@ -15,31 +29,20 @@ const helper = {}; return randomstring; }; - helper.getFrameJQuery = async ($iframe, includeSendkeys = false) => { + const getFrameJQuery = ($iframe) => { + /* + I tried over 9001 ways to inject javascript into iframes. + This is the only way I found that worked in IE 7+8+9, FF and Chrome + */ const win = $iframe[0].contentWindow; const doc = win.document; - const load = async (url) => { - const elem = doc.createElement('script'); - elem.setAttribute('src', url); - const p = new Promise((resolve, reject) => { - const handler = (evt) => { - elem.removeEventListener('load', handler); - elem.removeEventListener('error', handler); - if (evt.type === 'error') return reject(new Error(`failed to load ${url}`)); - resolve(); - }; - elem.addEventListener('load', handler); - elem.addEventListener('error', handler); - }); - doc.head.appendChild(elem); - await p; - }; + // IE 8+9 Hack to make eval appear + // https://stackoverflow.com/q/2720444 + win.execScript && win.execScript('null'); - if (!win.$) await load('../../static/js/vendors/jquery.js'); - // sendkeys.js depends on jQuery, so it cannot be loaded until jQuery has finished loading. (In - // other words, do not load both jQuery and sendkeys inside a Promise.all() call.) - if (!win.bililiteRange && includeSendkeys) await load('../tests/frontend/lib/sendkeys.js'); + win.eval(jsLibraries.jquery); + win.eval(jsLibraries.sendkeys); win.$.window = win; win.$.document = doc; @@ -48,21 +51,26 @@ const helper = {}; }; helper.clearSessionCookies = () => { - window.Cookies.remove('token'); - window.Cookies.remove('language'); + // Expire cookies, so author and language are changed after reloading the pad. See: + // https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_4_reset_the_previous_cookie + window.document.cookie = 'token=;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; + window.document.cookie = 'language=;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; }; // Can only happen when the iframe exists, so we're doing it separately from other cookies helper.clearPadPrefCookie = () => { - const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie'); - padcookie.clear(); + helper.padChrome$.document.cookie = 'prefsHttp=;expires=Thu, 01 Jan 1970 00:00:00 GMT'; }; - // Overwrite all prefs in pad cookie. + // Overwrite all prefs in pad cookie. Assumes http, not https. + // + // `helper.padChrome$.document.cookie` (the iframe) and `window.document.cookie` + // seem to have independent cookies, UNLESS we put path=/ here (which we don't). + // I don't fully understand it, but this function seems to properly simulate + // padCookie.setPref in the client code helper.setPadPrefCookie = (prefs) => { - const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie'); - padcookie.clear(); - for (const [key, value] of Object.entries(prefs)) padcookie.setPref(key, value); + helper.padChrome$.document.cookie = + (`prefsHttp=${escape(JSON.stringify(prefs))};expires=Thu, 01 Jan 3000 00:00:00 GMT`); }; // Functionality for knowing what key event type is required for tests @@ -81,33 +89,19 @@ const helper = {}; } helper.evtType = evtType; - // Deprecated; use helper.aNewPad() instead. - helper.newPad = (opts, id) => { - if (!id) id = `FRONTEND_TEST_${helper.randomString(20)}`; - opts = Object.assign({id}, typeof opts === 'function' ? {cb: opts} : opts); - const {cb = (err) => { if (err != null) throw err; }} = opts; - delete opts.cb; - helper.aNewPad(opts).then((id) => cb(null, id), (err) => cb(err || new Error(err))); - return id; - }; + // @todo needs fixing asap + // newPad occasionally timeouts, might be a problem with ready/onload code during page setup + // This ensures that tests run regardless of this problem + helper.retry = 0; - helper.aNewPad = async (opts = {}) => { - opts = Object.assign({ - _retry: 0, - clearCookies: true, - id: `FRONTEND_TEST_${helper.randomString(20)}`, - hookFns: {}, - }, opts); - - // Set up socket.io spying as early as possible. - /** chat messages received */ - helper.chatMessages = []; - /** changeset commits from the server */ - helper.commits = []; - /** userInfo messages from the server */ - helper.userInfos = []; - if (opts.hookFns._socketCreated == null) opts.hookFns._socketCreated = []; - opts.hookFns._socketCreated.unshift(() => helper.spyOnSocketIO()); + helper.newPad = (cb, padName) => { + // build opts object + let opts = {clearCookies: true}; + if (typeof cb === 'function') { + opts.cb = cb; + } else { + opts = _.defaults(cb, opts); + } // if opts.params is set we manipulate the URL to include URL parameters IE ?foo=Bah. let encodedParams; @@ -124,7 +118,10 @@ const helper = {}; helper.clearSessionCookies(); } - $iframe = $(``); + if (!padName) padName = `FRONTEND_TEST_${helper.randomString(20)}`; + $iframe = $(``); + // needed for retry + const origPadName = padName; // clean up inner iframe references helper.padChrome$ = helper.padOuter$ = helper.padInner$ = null; @@ -133,57 +130,55 @@ const helper = {}; $('#iframe-container iframe').remove(); // set new iframe $('#iframe-container').append($iframe); - await Promise.all([ - new Promise((resolve) => $iframe.one('load', resolve)), - // Install the hook functions as early as possible because some of them fire right away. - new Promise((resolve, reject) => { - if ($iframe[0].contentWindow._postPluginUpdateForTestingDone) { - return reject(new Error( - 'failed to set _postPluginUpdateForTesting before it would have been called')); + $iframe.one('load', () => { + helper.padChrome$ = getFrameJQuery($('#iframe-container iframe')); + if (opts.clearCookies) { + helper.clearPadPrefCookie(); + } + if (opts.padPrefs) { + helper.setPadPrefCookie(opts.padPrefs); + } + helper.waitFor(() => !$iframe.contents().find('#editorloadingbox') + .is(':visible'), 10000).done(() => { + helper.padOuter$ = getFrameJQuery(helper.padChrome$('iframe[name="ace_outer"]')); + helper.padInner$ = getFrameJQuery(helper.padOuter$('iframe[name="ace_inner"]')); + + // disable all animations, this makes tests faster and easier + helper.padChrome$.fx.off = true; + helper.padOuter$.fx.off = true; + helper.padInner$.fx.off = true; + + /* + * chat messages received + * @type {Array} + */ + helper.chatMessages = []; + + /* + * changeset commits from the server + * @type {Array} + */ + helper.commits = []; + + /* + * userInfo messages from the server + * @type {Array} + */ + helper.userInfos = []; + + // listen for server messages + helper.spyOnSocketIO(); + opts.cb(); + }).fail(() => { + if (helper.retry > 3) { + throw new Error('Pad never loaded'); } - $iframe[0].contentWindow._postPluginUpdateForTesting = () => { - const {hooks} = - $iframe[0].contentWindow.require('ep_etherpad-lite/static/js/pluginfw/plugin_defs'); - for (const [hookName, hookFns] of Object.entries(opts.hookFns)) { - if (hooks[hookName] == null) hooks[hookName] = []; - hooks[hookName].push( - ...hookFns.map((hookFn) => ({hook_name: hookName, hook_fn: hookFn}))); - } - resolve(); - }; - }), - ]); - helper.padChrome$ = await helper.getFrameJQuery($('#iframe-container iframe'), true); - helper.padChrome$.padeditor = - helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_editor').padeditor; - if (opts.clearCookies) { - helper.clearPadPrefCookie(); - } - if (opts.padPrefs) { - helper.setPadPrefCookie(opts.padPrefs); - } - const $loading = helper.padChrome$('#editorloadingbox'); - const $container = helper.padChrome$('#editorcontainer'); - try { - await helper.waitForPromise( - () => !$loading.is(':visible') && $container.hasClass('initialized'), 10000); - } catch (err) { - if (opts._retry++ >= 4) throw new Error('Pad never loaded'); - return await helper.aNewPad(opts); - } - helper.padOuter$ = - await helper.getFrameJQuery(helper.padChrome$('iframe[name="ace_outer"]'), false); - helper.padInner$ = - await helper.getFrameJQuery(helper.padOuter$('iframe[name="ace_inner"]'), true); + helper.retry++; + helper.newPad(cb, origPadName); + }); + }); - // disable all animations, this makes tests faster and easier - helper.padChrome$.fx.off = true; - helper.padOuter$.fx.off = true; - helper.padInner$.fx.off = true; - - // Don't return opts.id -- the server might have redirected the browser to a transformed version - // of the requested pad ID. - return helper.padChrome$.window.clientVars.padId; + return padName; }; helper.newAdmin = async (page) => { @@ -197,8 +192,8 @@ const helper = {}; $('#iframe-container iframe').remove(); // set new iframe $('#iframe-container').append($iframe); - $iframe.one('load', async () => { - helper.admin$ = await helper.getFrameJQuery($('#iframe-container iframe'), false); + $iframe.one('load', () => { + helper.admin$ = getFrameJQuery($('#iframe-container iframe')); }); }; @@ -274,22 +269,6 @@ const helper = {}; selection.addRange(range); }; - // Temporarily reduces minimum time between commits and calls the provided function with a single - // argument: a function that immediately incorporates all pad edits (as opposed to waiting for the - // idle timer to fire). - helper.withFastCommit = async (fn) => { - const incorp = () => helper.padChrome$.padeditor.ace.callWithAce( - (ace) => ace.ace_inCallStackIfNecessary('helper.edit', () => ace.ace_fastIncorp())); - const cc = helper.padChrome$.window.pad.collabClient; - const {commitDelay} = cc; - cc.commitDelay = 0; - try { - return await fn(incorp); - } finally { - cc.commitDelay = commitDelay; - } - }; - const getTextNodeAndOffsetOf = ($targetLine, targetOffsetAtLine) => { const $textNodes = $targetLine.find('*').contents().filter(function () { return this.nodeType === Node.TEXT_NODE; diff --git a/src/tests/frontend/helper/methods.ts b/src/tests/frontend/helper/methods.js similarity index 64% rename from src/tests/frontend/helper/methods.ts rename to src/tests/frontend/helper/methods.js index f1da92371..4b0391774 100644 --- a/src/tests/frontend/helper/methods.ts +++ b/src/tests/frontend/helper/methods.js @@ -1,4 +1,4 @@ -// @ts-nocheck +'use strict'; /** * Spys on socket.io messages and saves them into several arrays @@ -6,15 +6,14 @@ */ helper.spyOnSocketIO = () => { helper.contentWindow().pad.socket.on('message', (msg) => { - if (msg.type !== 'COLLABROOM') return; - if (msg.data.type === 'ACCEPT_COMMIT') { - helper.commits.push(msg); - } else if (msg.data.type === 'USER_NEWINFO') { - helper.userInfos.push(msg); - } else if (msg.data.type === 'CHAT_MESSAGE') { - helper.chatMessages.push(msg.data.message); - } else if (msg.data.type === 'CHAT_MESSAGES') { - helper.chatMessages.push(...msg.data.messages); + if (msg.type === 'COLLABROOM') { + if (msg.data.type === 'ACCEPT_COMMIT') { + helper.commits.push(msg); + } else if (msg.data.type === 'USER_NEWINFO') { + helper.userInfos.push(msg); + } else if (msg.data.type === 'CHAT_MESSAGE') { + helper.chatMessages.push(msg); + } } }); }; @@ -34,11 +33,8 @@ helper.spyOnSocketIO = () => { helper.edit = async (message, line) => { const editsNum = helper.commits.length; line = line ? line - 1 : 0; - await helper.withFastCommit(async (incorp) => { - helper.linesDiv()[line].sendkeys(message); - incorp(); - await helper.waitForPromise(() => editsNum + 1 === helper.commits.length, 10000); - }); + helper.linesDiv()[line].sendkeys(message); + return helper.waitForPromise(() => editsNum + 1 === helper.commits.length); }; /** @@ -49,7 +45,11 @@ helper.edit = async (message, line) => { * * @returns {Array.} array of divs */ -helper.linesDiv = () => helper.padInner$('.ace-line').map(function () { return $(this); }).get(); +helper.linesDiv = () => { + return helper.padInner$('.ace-line').map(function () { + return $(this); + }).get(); +}; /** * The pad text as an array of lines @@ -81,10 +81,10 @@ helper.defaultText = * @param {string} message the chat message to be sent * @returns {Promise} */ -helper.sendChatMessage = async (message) => { +helper.sendChatMessage = (message) => { const noOfChatMessages = helper.chatMessages.length; helper.padChrome$('#chatinput').sendkeys(message); - await helper.waitForPromise(() => noOfChatMessages + 1 === helper.chatMessages.length); + return helper.waitForPromise(() => noOfChatMessages + 1 === helper.chatMessages.length); }; /** @@ -92,10 +92,11 @@ helper.sendChatMessage = async (message) => { * * @returns {Promise} */ -helper.showSettings = async () => { - if (helper.isSettingsShown()) return; - helper.settingsButton().trigger('click'); - await helper.waitForPromise(() => helper.isSettingsShown(), 2000); +helper.showSettings = () => { + if (!helper.isSettingsShown()) { + helper.settingsButton().click(); + return helper.waitForPromise(() => helper.isSettingsShown(), 2000); + } }; /** @@ -104,10 +105,11 @@ helper.showSettings = async () => { * @returns {Promise} * @todo untested */ -helper.hideSettings = async () => { - if (!helper.isSettingsShown()) return; - helper.settingsButton().trigger('click'); - await helper.waitForPromise(() => !helper.isSettingsShown(), 2000); +helper.hideSettings = () => { + if (helper.isSettingsShown()) { + helper.settingsButton().click(); + return helper.waitForPromise(() => !helper.isSettingsShown(), 2000); + } }; /** @@ -116,11 +118,12 @@ helper.hideSettings = async () => { * * @returns {Promise} */ -helper.enableStickyChatviaSettings = async () => { +helper.enableStickyChatviaSettings = () => { const stickyChat = helper.padChrome$('#options-stickychat'); - if (!helper.isSettingsShown() || stickyChat.is(':checked')) return; - stickyChat.trigger('click'); - await helper.waitForPromise(() => helper.isChatboxSticky(), 2000); + if (helper.isSettingsShown() && !stickyChat.is(':checked')) { + stickyChat.click(); + return helper.waitForPromise(() => helper.isChatboxSticky(), 2000); + } }; /** @@ -129,11 +132,12 @@ helper.enableStickyChatviaSettings = async () => { * * @returns {Promise} */ -helper.disableStickyChatviaSettings = async () => { +helper.disableStickyChatviaSettings = () => { const stickyChat = helper.padChrome$('#options-stickychat'); - if (!helper.isSettingsShown() || !stickyChat.is(':checked')) return; - stickyChat.trigger('click'); - await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); + if (helper.isSettingsShown() && stickyChat.is(':checked')) { + stickyChat.click(); + return helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); + } }; /** @@ -142,11 +146,12 @@ helper.disableStickyChatviaSettings = async () => { * * @returns {Promise} */ -helper.enableStickyChatviaIcon = async () => { +helper.enableStickyChatviaIcon = () => { const stickyChat = helper.padChrome$('#titlesticky'); - if (!helper.isChatboxShown() || helper.isChatboxSticky()) return; - stickyChat.trigger('click'); - await helper.waitForPromise(() => helper.isChatboxSticky(), 2000); + if (helper.isChatboxShown() && !helper.isChatboxSticky()) { + stickyChat.click(); + return helper.waitForPromise(() => helper.isChatboxSticky(), 2000); + } }; /** @@ -155,10 +160,11 @@ helper.enableStickyChatviaIcon = async () => { * * @returns {Promise} */ -helper.disableStickyChatviaIcon = async () => { - if (!helper.isChatboxShown() || !helper.isChatboxSticky()) return; - helper.titlecross().trigger('click'); - await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); +helper.disableStickyChatviaIcon = () => { + if (helper.isChatboxShown() && helper.isChatboxSticky()) { + helper.titlecross().click(); + return helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); + } }; /** @@ -173,11 +179,12 @@ helper.disableStickyChatviaIcon = async () => { * @todo for some reason this does only work the first time, you cannot * goto rev 0 and then via the same method to rev 5. Use buttons instead */ -helper.gotoTimeslider = async (revision) => { +helper.gotoTimeslider = (revision) => { revision = Number.isInteger(revision) ? `#${revision}` : ''; - helper.padChrome$.window.location.href = - `${helper.padChrome$.window.location.pathname}/timeslider${revision}`; - await helper.waitForPromise(() => helper.timesliderTimerTime() && + const iframe = $('#iframe-container iframe'); + iframe.attr('src', `${iframe.attr('src')}/timeslider${revision}`); + + return helper.waitForPromise(() => helper.timesliderTimerTime() && !Number.isNaN(new Date(helper.timesliderTimerTime()).getTime()), 10000); }; @@ -220,10 +227,7 @@ helper.clearPad = async () => { await helper.waitForPromise(() => !helper.padInner$.document.getSelection().isCollapsed); const e = new helper.padInner$.Event(helper.evtType); e.keyCode = 8; // delete key - await helper.withFastCommit(async (incorp) => { - helper.padInner$('#innerdocbody').trigger(e); - incorp(); - await helper.waitForPromise(helper.padIsEmpty); - await helper.waitForPromise(() => helper.commits.length > commitsBefore); - }); + helper.padInner$('#innerdocbody').trigger(e); + await helper.waitForPromise(helper.padIsEmpty); + await helper.waitForPromise(() => helper.commits.length > commitsBefore); }; diff --git a/src/tests/frontend/helper/multipleUsers.ts b/src/tests/frontend/helper/multipleUsers.ts deleted file mode 100644 index 261b8f63c..000000000 --- a/src/tests/frontend/helper/multipleUsers.ts +++ /dev/null @@ -1,93 +0,0 @@ -// @ts-nocheck - -const getCookies = - () => helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_utils').Cookies; - -const setToken = (token) => getCookies().set('token', token); - -const getToken = () => getCookies().get('token'); - -const startActingLike = (user) => { - helper.padChrome$ = user.padChrome$; - helper.padOuter$ = user.padOuter$; - helper.padInner$ = user.padInner$; - if (helper.padChrome$) setToken(user.token); -}; - -const clearToken = () => getCookies().remove('token'); - -helper.multipleUsers = { - _user0: null, - _user1: null, - - // open the same pad on different frames (allows concurrent editions to pad) - async init() { - this._user0 = { - $frame: $('#iframe-container iframe'), - token: getToken(), - // we'll switch between pads, need to store current values of helper.pad* - // to be able to restore those values later - padChrome$: helper.padChrome$, - padOuter$: helper.padOuter$, - padInner$: helper.padInner$, - }; - this._user1 = {}; - // Force generation of a new token. - clearToken(); - // need to perform as the other user, otherwise we'll get the userdup error message - await this.performAsOtherUser(this._createUser1Frame.bind(this)); - }, - - async performAsOtherUser(action) { - startActingLike(this._user1); - await action(); - startActingLike(this._user0); - }, - - close() { - this._user0.$frame.attr('style', ''); // make the default ocopy the full height - this._user1.$frame.remove(); - }, - - async _loadJQueryForUser1Frame() { - this._user1.padChrome$ = await helper.getFrameJQuery(this._user1.$frame, true); - this._user1.padOuter$ = - await helper.getFrameJQuery(this._user1.padChrome$('iframe[name="ace_outer"]'), false); - this._user1.padInner$ = - await helper.getFrameJQuery(this._user1.padOuter$('iframe[name="ace_inner"]'), true); - - // update helper vars now that they are available - helper.padChrome$ = this._user1.padChrome$; - helper.padOuter$ = this._user1.padOuter$; - helper.padInner$ = this._user1.padInner$; - }, - - async _createUser1Frame() { - this._user0.$frame.css({height: '50%'}); - this._user1.$frame = $('`); + $originalPadFrame = $('#iframe-container iframe'); + $otherIframeWithSamePad.insertAfter($originalPadFrame); - // open same pad on another iframe, to force userdup error - const $otherIframeWithSamePad = $(``); - $originalPadFrame = $('#iframe-container iframe'); - $otherIframeWithSamePad.insertAfter($originalPadFrame); + // wait for modal to be displayed + helper.waitFor(() => $errorMessageModal.is(':visible'), 50000).done(done); + }); - // wait for modal to be displayed - await helper.waitForPromise(() => $errorMessageModal.is(':visible'), 50000); + this.timeout(60000); }); - it('displays a count down timer to automatically reconnect', async function () { + it('displays a count down timer to automatically reconnect', function (done) { const $errorMessageModal = helper.padChrome$('#connectivity .userdup'); const $countDownTimer = $errorMessageModal.find('.reconnecttimer'); expect($countDownTimer.is(':visible')).to.be(true); + + done(); }); context('and user clicks on Cancel', function () { beforeEach(async function () { const $errorMessageModal = helper.padChrome$('#connectivity .userdup'); - $errorMessageModal.find('#cancelreconnect').trigger('click'); + $errorMessageModal.find('#cancelreconnect').click(); await helper.waitForPromise( () => helper.padChrome$('#connectivity .userdup').is(':visible') === true); }); - it('does not show Cancel button nor timer anymore', async function () { + it('does not show Cancel button nor timer anymore', function (done) { const $errorMessageModal = helper.padChrome$('#connectivity .userdup'); const $countDownTimer = $errorMessageModal.find('.reconnecttimer'); const $cancelButton = $errorMessageModal.find('#cancelreconnect'); expect($countDownTimer.is(':visible')).to.be(false); expect($cancelButton.is(':visible')).to.be(false); + + done(); }); }); context('and user does not click on Cancel until timer expires', function () { - it('reloads the pad', async function () { + let padWasReloaded = false; + + beforeEach(async function () { + $originalPadFrame.one('load', () => { + padWasReloaded = true; + }); + }); + + it('reloads the pad', function (done) { + helper.waitFor(() => padWasReloaded, 10000).done(done); + this.timeout(10000); - await new Promise((resolve) => $originalPadFrame.one('load', resolve)); }); }); }); diff --git a/src/tests/frontend/travis/adminrunner.sh b/src/tests/frontend/travis/adminrunner.sh index 32fd12a63..8f57ac6fb 100755 --- a/src/tests/frontend/travis/adminrunner.sh +++ b/src/tests/frontend/travis/adminrunner.sh @@ -6,13 +6,16 @@ error() { log "ERROR: $@" >&2; } fatal() { error "$@"; exit 1; } try() { "$@" || fatal "'$@' failed"; } +[ -n "${SAUCE_USERNAME}" ] || fatal "SAUCE_USERNAME is unset - exiting" +[ -n "${SAUCE_ACCESS_KEY}" ] || fatal "SAUCE_ACCESS_KEY is unset - exiting" + # Move to the Etherpad base directory. MY_DIR=$(try cd "${0%/*}" && try pwd -P) || exit 1 try cd "${MY_DIR}/../../../.." log "Assuming src/bin/installDeps.sh has already been run" -( cd src && npm run dev --experimental-worker "${@}" & -ep_pid=$!) +node src/node/server.js --experimental-worker "${@}" & +ep_pid=$! log "Waiting for Etherpad to accept connections (http://localhost:9001)..." connected=false @@ -35,6 +38,7 @@ log "Starting the remote runner..." node remote_runner.js admin exit_code=$? +kill "$(cat /tmp/sauce.pid)" kill "$ep_pid" && wait "$ep_pid" log "Done." exit "$exit_code" diff --git a/src/tests/frontend/travis/remote_runner.js b/src/tests/frontend/travis/remote_runner.js index 331e38568..5a75dbe66 100644 --- a/src/tests/frontend/travis/remote_runner.js +++ b/src/tests/frontend/travis/remote_runner.js @@ -1,111 +1,194 @@ 'use strict'; -// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an -// unhandled rejection into an uncaught exception, which does cause Node.js to exit. -process.on('unhandledRejection', (err) => { throw err; }); - const async = require('async'); -const swd = require('selenium-webdriver'); -const swdChrome = require('selenium-webdriver/chrome'); -const swdEdge = require('selenium-webdriver/edge'); -const swdFirefox = require('selenium-webdriver/firefox'); +const wd = require('wd'); + +const config = { + host: 'ondemand.saucelabs.com', + port: 80, + username: process.env.SAUCE_USER, + accessKey: process.env.SAUCE_ACCESS_KEY, +}; const isAdminRunner = process.argv[2] === 'admin'; -const colorSubst = { - red: '\x1B[31m', - yellow: '\x1B[33m', - green: '\x1B[32m', - clear: '\x1B[39m', -}; -const colorRegex = new RegExp(`\\[(${Object.keys(colorSubst).join('|')})\\]`, 'g'); +let allTestsPassed = true; +// overwrite the default exit code +// in case not all worker can be run (due to saucelabs limits), +// `queue.drain` below will not be called +// and the script would silently exit with error code 0 +process.exitCode = 2; +process.on('exit', (code) => { + if (code === 2) { + console.log('\x1B[31mFAILED\x1B[39m Not all saucelabs runner have been started.'); + } +}); -const log = (msg, pfx = '') => { - console.log(`${pfx}${msg.replace(colorRegex, (m, p1) => colorSubst[p1])}`); -}; +const sauceTestWorker = async.queue((testSettings, callback) => { + const browser = wd.promiseChainRemote( + config.host, config.port, config.username, config.accessKey); + const name = + `${process.env.GIT_HASH} - ${testSettings.browserName} ` + + `${testSettings.version}, ${testSettings.platform}`; + testSettings.name = name; + testSettings.public = true; + testSettings.build = process.env.GIT_HASH; + // console.json can be downloaded via saucelabs, + // don't know how to print them into output of the tests + testSettings.extendedDebugging = true; + testSettings.tunnelIdentifier = process.env.TRAVIS_JOB_NUMBER; -const finishedRegex = /FINISHED.*[0-9]+ tests passed, ([0-9]+) tests failed/; + browser.init(testSettings).get('http://localhost:9001/tests/frontend/', () => { + const url = `https://saucelabs.com/jobs/${browser.sessionID}`; + console.log(`Remote sauce test '${name}' started! ${url}`); -const sauceTestWorker = async.queue(async ({name, pfx, browser, version, platform}) => { - const chromeOptions = new swdChrome.Options() - .addArguments('use-fake-device-for-media-stream', 'use-fake-ui-for-media-stream'); - const edgeOptions = new swdEdge.Options() - .addArguments('use-fake-device-for-media-stream', 'use-fake-ui-for-media-stream'); - const firefoxOptions = new swdFirefox.Options() - .setPreference('media.navigator.permission.disabled', true) - .setPreference('media.navigator.streams.fake', true); - const builder = new swd.Builder() - .usingServer('https://ondemand.saucelabs.com/wd/hub') - .forBrowser(browser, version, platform) - .setChromeOptions(chromeOptions) - .setEdgeOptions(edgeOptions) - .setFirefoxOptions(firefoxOptions); - builder.getCapabilities().set('sauce:options', { - username: process.env.SAUCE_USERNAME, - accessKey: process.env.SAUCE_ACCESS_KEY, - name: [process.env.GIT_HASH].concat(process.env.SAUCE_NAME || [], name).join(' - '), - public: true, - build: process.env.GIT_HASH, - // console.json can be downloaded via saucelabs, - // don't know how to print them into output of the tests - extendedDebugging: true, - tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER, - }); - const driver = await builder.build(); - const url = `https://saucelabs.com/jobs/${(await driver.getSession()).getId()}`; - try { - await driver.get('http://localhost:9001/tests/frontend/'); - log(`Remote sauce test started! ${url}`, pfx); - // @TODO this should be configured in testSettings, see - // https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options#TestConfigurationOptions-Timeouts - const deadline = Date.now() + 14.5 * 60 * 1000; // Slightly less than overall test timeout. + // tear down the test excecution + const stopSauce = (success, timesup) => { + clearInterval(getStatusInterval); + clearTimeout(timeout); + + browser.quit(() => { + if (!success) { + allTestsPassed = false; + } + + // if stopSauce is called via timeout + // (in contrast to via getStatusInterval) than the log of up to the last + // five seconds may not be available here. It's an error anyway, so don't care about it. + printLog(logIndex); + + if (timesup) { + console.log(`[${testSettings.browserName} ${testSettings.platform}` + + `${testSettings.version === '' ? '' : (` ${testSettings.version}`)}]` + + ' \x1B[31mFAILED\x1B[39m allowed test duration exceeded'); + } + console.log(`Remote sauce test '${name}' finished! ${url}`); + + callback(); + }); + }; + + /** + * timeout if a test hangs or the job exceeds 14.5 minutes + * It's necessary because if travis kills the saucelabs session due to inactivity, + * we don't get any output + * @todo this should be configured in testSettings, see + * https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options#TestConfigurationOptions-Timeouts + */ + const timeout = setTimeout(() => { + stopSauce(false, true); + }, 870000); // travis timeout is 15 minutes, set this to a slightly lower value + + let knownConsoleText = ''; // how many characters of the log have been sent to travis let logIndex = 0; - const remoteFn = (skipChars) => { - const console = document.getElementById('console'); // eslint-disable-line no-undef - if (console == null) return ''; - let text = ''; - for (const n of console.childNodes) { - if (n.nodeType === n.TEXT_NODE) text += n.data; - } - return text.substring(skipChars); + const getStatusInterval = setInterval(() => { + browser.eval("$('#console').text()", (err, consoleText) => { + if (!consoleText || err) { + return; + } + knownConsoleText = consoleText; + + if (knownConsoleText.indexOf('FINISHED') > 0) { + const match = knownConsoleText.match( + /FINISHED.*([0-9]+) tests passed, ([0-9]+) tests failed/); + // finished without failures + if (match[2] && match[2] === '0') { + stopSauce(true); + + // finished but some tests did not return or some tests failed + } else { + stopSauce(false); + } + } else { + // not finished yet + printLog(logIndex); + logIndex = knownConsoleText.length; + } + }); + }, 5000); + + /** + * Replaces color codes in the test runners log, appends + * browser name, platform etc. to every line and prints them. + * + * @param {number} index offset from where to start + */ + const printLog = (index) => { + let testResult = knownConsoleText.substring(index) + .replace(/\[red\]/g, '\x1B[31m').replace(/\[yellow\]/g, '\x1B[33m') + .replace(/\[green\]/g, '\x1B[32m').replace(/\[clear\]/g, '\x1B[39m'); + testResult = testResult.split('\\n').map((line) => `[${testSettings.browserName} ` + + `${testSettings.platform}` + + `${testSettings.version === '' ? '' : (` ${testSettings.version}`)}]` + + `${line}`).join('\n'); + + console.log(testResult); }; - while (true) { - const consoleText = await driver.executeScript(remoteFn, logIndex); - (consoleText ? consoleText.split('\n') : []).forEach((line) => log(line, pfx)); - logIndex += consoleText.length; - const [finished, nFailedStr] = consoleText.match(finishedRegex) || []; - if (finished) { - if (nFailedStr !== '0') process.exitCode = 1; - break; - } - if (Date.now() >= deadline) { - log('[red]FAILED[clear] allowed test duration exceeded'); - process.exitCode = 1; - break; - } - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - } finally { - log(`Remote sauce test finished! ${url}`, pfx); - await driver.quit(); - } + }); }, 6); // run 6 tests in parrallel -Promise.all([ - {browser: 'chrome', version: 'latest', platform: 'Windows 10'}, - ...(isAdminRunner ? [] : [ - {browser: 'safari', version: 'latest', platform: 'macOS 11.00'}, - {browser: 'firefox', version: 'latest', platform: 'Windows 10'}, - {browser: 'MicrosoftEdge', version: 'latest', platform: 'Windows 10'}, - ]), -].map(async ({browser, version, platform}) => { - const name = `${browser} ${version}, ${platform}`; - const pfx = `[${name}] `; - try { - await sauceTestWorker.push({name, pfx, browser, version, platform}); - } catch (err) { - log(`[red]FAILED[clear] ${err.stack || err}`, pfx); - process.exitCode = 1; - } -})); +if (!isAdminRunner) { + // 1) Firefox on Linux + sauceTestWorker.push({ + platform: 'Windows 7', + browserName: 'firefox', + version: '52.0', + }); + + // 2) Chrome on Linux + sauceTestWorker.push({ + platform: 'Windows 7', + browserName: 'chrome', + version: '55.0', + args: ['--use-fake-device-for-media-stream'], + }); + + /* + // 3) Safari on OSX 10.15 + sauceTestWorker.push({ + 'platform' : 'OS X 10.15' + , 'browserName' : 'safari' + , 'version' : '13.1' + }); + */ + + // 4) Safari on OSX 10.14 + sauceTestWorker.push({ + platform: 'OS X 10.15', + browserName: 'safari', + version: '13.1', + }); + // IE 10 doesn't appear to be working anyway + /* + // 4) IE 10 on Win 8 + sauceTestWorker.push({ + 'platform' : 'Windows 8' + , 'browserName' : 'iexplore' + , 'version' : '10.0' + }); + */ + // 5) Edge on Win 10 + sauceTestWorker.push({ + platform: 'Windows 10', + browserName: 'microsoftedge', + version: '83.0', + }); + // 6) Firefox on Win 7 + sauceTestWorker.push({ + platform: 'Windows 7', + browserName: 'firefox', + version: '78.0', + }); +} else { + // 4) Safari on OSX 10.14 + sauceTestWorker.push({ + platform: 'OS X 10.15', + browserName: 'safari', + version: '13.1', + }); +} + +sauceTestWorker.drain(() => { + process.exit(allTestsPassed ? 0 : 1); +}); diff --git a/src/tests/frontend/travis/runner.sh b/src/tests/frontend/travis/runner.sh index c2c2907e3..5a16ccceb 100755 --- a/src/tests/frontend/travis/runner.sh +++ b/src/tests/frontend/travis/runner.sh @@ -6,13 +6,16 @@ error() { log "ERROR: $@" >&2; } fatal() { error "$@"; exit 1; } try() { "$@" || fatal "'$@' failed"; } +[ -n "${SAUCE_USERNAME}" ] || fatal "SAUCE_USERNAME is unset - exiting" +[ -n "${SAUCE_ACCESS_KEY}" ] || fatal "SAUCE_ACCESS_KEY is unset - exiting" + # Move to the Etherpad base directory. MY_DIR=$(try cd "${0%/*}" && try pwd -P) || exit 1 try cd "${MY_DIR}/../../../.." -log "Assuming bin/installDeps.sh has already been run" -(cd src && npm run dev --experimental-worker "${@}" & -ep_pid=$!) +log "Assuming src/bin/installDeps.sh has already been run" +node src/node/server.js --experimental-worker "${@}" & +ep_pid=$! log "Waiting for Etherpad to accept connections (http://localhost:9001)..." connected=false @@ -35,6 +38,7 @@ log "Starting the remote runner..." node remote_runner.js exit_code=$? +kill "$(cat /tmp/sauce.pid)" kill "$ep_pid" && wait "$ep_pid" log "Done." exit "$exit_code" diff --git a/src/tests/frontend/travis/runnerBackend.sh b/src/tests/frontend/travis/runnerBackend.sh index 518e77872..f12ff25c1 100755 --- a/src/tests/frontend/travis/runnerBackend.sh +++ b/src/tests/frontend/travis/runnerBackend.sh @@ -19,8 +19,8 @@ s!"points":[^,]*!"points": 1000! log "Deprecation notice: runnerBackend.sh - Please use: cd src && npm test" log "Assuming src/bin/installDeps.sh has already been run" -(cd src && npm run dev "${@}" & -ep_pid=$!) +node src/node/server.js "${@}" & +ep_pid=$! log "Waiting for Etherpad to accept connections (http://localhost:9001)..." connected=false diff --git a/src/tests/frontend/travis/runnerLoadTest.sh b/src/tests/frontend/travis/runnerLoadTest.sh index 250d01f19..3fce737bc 100755 --- a/src/tests/frontend/travis/runnerLoadTest.sh +++ b/src/tests/frontend/travis/runnerLoadTest.sh @@ -1,22 +1,15 @@ #!/bin/sh -set -e - pecho() { printf %s\\n "$*"; } log() { pecho "$@"; } error() { log "ERROR: $@" >&2; } fatal() { error "$@"; exit 1; } try() { "$@" || fatal "'$@' failed"; } -[ -n "$1" ] && [ "$1" -gt 0 ] || fatal "no duration specified" -[ -n "$2" ] && [ "$2" -gt 0 ] || fatal "no authors specified" - # Move to the Etherpad base directory. MY_DIR=$(try cd "${0%/*}" && try pwd -P) || exit 1 try cd "${MY_DIR}/../../../.." -sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json.template - try sed -e ' s!"loadTest":[^,]*!"loadTest": true! # Reduce rate limit aggressiveness @@ -24,8 +17,8 @@ s!"points":[^,]*!"points": 1000! ' settings.json.template >settings.json log "Assuming src/bin/installDeps.sh has already been run" -(cd src && pnpm run prod & -ep_pid=$!) +node src/node/server.js "${@}" >/dev/null & +ep_pid=$! log "Waiting for Etherpad to accept connections (http://localhost:9001)..." connected=false @@ -35,7 +28,7 @@ can_connect() { } now() { date +%s; } start=$(now) -while [ $(($(now) - $start)) -le 60 ] && ! can_connect; do +while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do sleep 1 done [ "$connected" = true ] \ @@ -49,9 +42,7 @@ try curl http://localhost:9001/p/minifyme -f -s >/dev/null sleep 10 log "Running the load tests..." -# -d is duration of test, -a is number of authors to test with -# by specifying the number of authors we set the overall rate of messages -etherpad-loadtest -d "$1" -a "$2" +etherpad-loadtest -d 25 exit_code=$? kill "$ep_pid" && wait "$ep_pid" diff --git a/src/tests/frontend/travis/sauce_tunnel.sh b/src/tests/frontend/travis/sauce_tunnel.sh new file mode 100755 index 000000000..45827d31e --- /dev/null +++ b/src/tests/frontend/travis/sauce_tunnel.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +pecho() { printf %s\\n "$*"; } +log() { pecho "$@"; } +error() { log "ERROR: $@" >&2; } +fatal() { error "$@"; exit 1; } +try() { "$@" || fatal "'$@' failed"; } + +[ -n "${SAUCE_USERNAME}" ] || fatal "SAUCE_USERNAME is unset - exiting" +[ -n "${SAUCE_ACCESS_KEY}" ] || fatal "SAUCE_ACCESS_KEY is unset - exiting" + +# download and unzip the sauce connector +# +# ACHTUNG: as of 2019-12-21, downloading sc-latest-linux.tar.gz does not work. +# It is necessary to explicitly download a specific version, for example +# https://saucelabs.com/downloads/sc-4.5.4-linux.tar.gz Supported versions are +# currently listed at: +# https://wiki.saucelabs.com/display/DOCS/Downloading+Sauce+Connect+Proxy +try curl -o /tmp/sauce.tar.gz \ + https://saucelabs.com/downloads/sc-4.6.2-linux.tar.gz +try tar zxf /tmp/sauce.tar.gz --directory /tmp +try mv /tmp/sc-*-linux /tmp/sauce_connect + +# start the sauce connector in background and make sure it doesn't output the +# secret key +try rm -f /tmp/tunnel +/tmp/sauce_connect/bin/sc \ + --user "${SAUCE_USERNAME}" \ + --key "${SAUCE_ACCESS_KEY}" \ + -i "${TRAVIS_JOB_NUMBER}" \ + --pidfile /tmp/sauce.pid \ + --readyfile /tmp/tunnel >/dev/null & + +# wait for the tunnel to build up +while ! [ -e "/tmp/tunnel" ]; do + sleep 1 +done diff --git a/src/tests/ratelimit/Dockerfile.anotherip b/src/tests/ratelimit/Dockerfile.anotherip index c352b4af1..57f02f628 100644 --- a/src/tests/ratelimit/Dockerfile.anotherip +++ b/src/tests/ratelimit/Dockerfile.anotherip @@ -1,4 +1,4 @@ -FROM node:latest +FROM node:alpine3.12 WORKDIR /tmp RUN npm i etherpad-cli-client COPY ./src/tests/ratelimit/send_changesets.js /tmp/send_changesets.js diff --git a/src/tests/settings.json b/src/tests/settings.json deleted file mode 100644 index 9fef0fa19..000000000 --- a/src/tests/settings.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Etherpad","favicon":null,"skinName":"colibris","skinVariants":"super-light-toolbar super-light-editor light-background","ip":"0.0.0.0","port":9001,"showSettingsInAdminPage":true,"dbType":"dirty","dbSettings":{"filename":"var/dirty.db"},"defaultPadText":"Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https://etherpad.org\n","padOptions":{"noColors":false,"showControls":true,"showChat":true,"showLineNumbers":true,"useMonospaceFont":false,"userName":null,"userColor":null,"rtl":false,"alwaysShowChat":false,"chatAndUsers":false,"lang":null},"padShortcutEnabled":{"altF9":true,"altC":true,"cmdShift2":true,"delete":true,"return":true,"esc":true,"cmdS":true,"tab":true,"cmdZ":true,"cmdY":true,"cmdI":true,"cmdB":true,"cmdU":true,"cmd5":true,"cmdShiftL":true,"cmdShiftN":true,"cmdShift1":true,"cmdShiftC":true,"cmdH":true,"ctrlHome":true,"pageUp":true,"pageDown":true},"suppressErrorsInPadText":false,"requireSession":false,"editOnly":false,"minify":true,"maxAge":21600,"abiword":null,"soffice":null,"allowUnknownFileEnds":true,"requireAuthentication":false,"requireAuthorization":false,"trustProxy":false,"cookie":{"keyRotationInterval":86400000,"sameSite":"Lax","sessionLifetime":864000000,"sessionRefreshInterval":86400000},"disableIPlogging":false,"automaticReconnectionTimeout":0,"scrollWhenFocusLineIsOutOfViewport":{"percentage":{"editionAboveViewport":0,"editionBelowViewport":0},"duration":0,"scrollWhenCaretIsInTheLastLineOfViewport":false,"percentageToScrollWhenUserPressesArrowUp":0},"users":{"admin":{"password":"changeme1","is_admin":true},"user":{"password":"changeme1","is_admin":false}},"socketTransportProtocols":["websocket","polling"],"socketIo":{"maxHttpBufferSize":1000000},"loadTest":false,"dumpOnUncleanExit":false,"importExportRateLimiting":{"windowMs":90000,"max":10},"importMaxFileSize":52428800,"commitRateLimiting":{"duration":1,"points":10},"exposeVersion":false,"loglevel":"INFO","customLocaleStrings":{},"enableAdminUITests":true,"lowerCasePadIds":false,"sso":{"issuer":"${SSO_ISSUER:http://localhost:9001}","clients":[{"client_id":"${ADMIN_CLIENT:admin_client}","client_secret":"${ADMIN_SECRET:admin}","grant_types":["authorization_code"],"response_types":["code"],"redirect_uris":["${ADMIN_REDIRECT:http://localhost:9001/admin/}","https://oauth.pstmn.io/v1/callback"]},{"client_id":"${USER_CLIENT:user_client}","client_secret":"${USER_SECRET:user}","grant_types":["authorization_code"],"response_types":["code"],"redirect_uris":["${USER_REDIRECT:http://localhost:9001/}"]}]}} \ No newline at end of file diff --git a/src/tsconfig.json b/src/tsconfig.json deleted file mode 100644 index a42ef0188..000000000 --- a/src/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - "moduleDetection": "force", - "lib": ["ES2023"], - /* Language and Environment */ - "target": "es6", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - /* Modules */ - "module": "CommonJS", /* Specify what module code is generated. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ - /* Completeness */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */, - "resolveJsonModule": true - } -} diff --git a/src/vitest.config.ts b/src/vitest.config.ts deleted file mode 100644 index c47c424cc..000000000 --- a/src/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - include: ["tests/backend-new/specs/**/*.ts"], - }, -}) diff --git a/src/web.config b/src/web.config index 65f2cf03f..bd50a60c5 100644 --- a/src/web.config +++ b/src/web.config @@ -2,7 +2,7 @@ - + @@ -13,7 +13,7 @@ - + --> @@ -23,7 +23,7 @@ - + diff --git a/start.bat b/start.bat index bf8f1b23d..7e9264ee3 100644 --- a/start.bat +++ b/start.bat @@ -8,5 +8,4 @@ REM around this, everything must consistently use either `src` or REM `node_modules\ep_etherpad-lite` on Windows. Because some plugins access REM Etherpad internals via `require('ep_etherpad-lite/foo')`, REM `node_modules\ep_etherpad-lite` is used here. -cd src -pnpm run prod +node node_modules\ep_etherpad-lite\node\server.js diff --git a/ui/.gitignore b/ui/.gitignore deleted file mode 100644 index a547bf36d..000000000 --- a/ui/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/ui/consent.html b/ui/consent.html deleted file mode 100644 index 502b95a2e..000000000 --- a/ui/consent.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - Consent Etherpad - - -
- -
- - - diff --git a/ui/login.html b/ui/login.html deleted file mode 100644 index 0ff588363..000000000 --- a/ui/login.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - SSO Etherpad - - -
- -
- - - diff --git a/ui/package.json b/ui/package.json deleted file mode 100644 index 6c24e239f..000000000 --- a/ui/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "ui", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "build-copy": "tsc && vite build --outDir ../src/static/oidc --emptyOutDir" - }, - "devDependencies": { - "ep_etherpad-lite": "workspace:../src", - "typescript": "^5.8.2", - "vite": "^6.3.4" - } -} diff --git a/ui/pad.html b/ui/pad.html deleted file mode 100644 index e11541943..000000000 --- a/ui/pad.html +++ /dev/null @@ -1,686 +0,0 @@ - - - - - - Etherpad - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - -
- - - -
- - - - - - - -
- -
- -
-

- You do not have permission to access this pad -

-
- - -

-
- Loading... -

- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - - 0 -
- -
-
-
-

- - █   -
-
-
- -
-
-
- -
-
-
-
-
- - - - - - - - - - -
- - - - - - - - - - - - - - diff --git a/ui/src/consent.ts b/ui/src/consent.ts deleted file mode 100644 index 79f6214fe..000000000 --- a/ui/src/consent.ts +++ /dev/null @@ -1,35 +0,0 @@ -import "./style.css" -//import {MapArrayType} from "ep_etherpad-lite/node/types/MapType"; - -const form = document.querySelector('form')!; -const sessionId = new URLSearchParams(window.location.search).get('state'); - -form.action = '/interaction/' + sessionId; - -/*form.addEventListener('submit', function (event) { - event.preventDefault(); - const formData = new FormData(form); - const data: MapArrayType = {}; - formData.forEach((value, key) => { - data[key] = value; - }); - const sessionId = new URLSearchParams(window.location.search).get('state'); - - fetch('/interaction/' + sessionId, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }).then(response => { - if (response.ok) { - if (response.redirected) { - window.location.href = response.url; - } - } else { - document.getElementById('error')!.innerText = "Error signing in"; - } - }).catch(error => { - document.getElementById('error')!.innerText = "Error signing in" + error; - }) -});*/ diff --git a/ui/src/main.ts b/ui/src/main.ts deleted file mode 100644 index 1ff174cdb..000000000 --- a/ui/src/main.ts +++ /dev/null @@ -1,58 +0,0 @@ -import './style.css' -import {MapArrayType} from "ep_etherpad-lite/node/types/MapType.ts"; - -const searchParams = new URLSearchParams(window.location.search); - - -document.getElementById('client')!.innerText = searchParams.get('client_id')!; - -const form = document.querySelector('form')!; -form.addEventListener('submit', function (event) { - event.preventDefault(); - const formData = new FormData(form); - const data: MapArrayType = {}; - formData.forEach((value, key) => { - data[key] = value; - }); - const sessionId = new URLSearchParams(window.location.search).get('state'); - - fetch('/interaction/' + sessionId, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - redirect: 'follow', - body: JSON.stringify(data), - }).then(response => { - if (response.ok) { - if (response.redirected) { - window.location.href = response.url; - } - } else { - document.getElementById('error')!.innerText = "Error signing in"; - } - }).catch(error => { - document.getElementById('error')!.innerText = "Error signing in" + error; - }) -}); - -const hidePassword = document.querySelector('.toggle-password-visibility')! as HTMLElement -const showPassword = document.getElementById('eye-hide')! as HTMLElement -const togglePasswordVisibility = () => { - const passwordInput = document.getElementsByName('password')[0] as HTMLInputElement; - if (passwordInput.type === 'password') { - showPassword.style.display = 'block'; - hidePassword.style.display = 'none'; - passwordInput.type = 'text'; - } else { - showPassword.style.display = 'none'; - hidePassword.style.display = 'block'; - passwordInput.type = 'password'; - } -} - - -hidePassword.addEventListener('click', togglePasswordVisibility); -showPassword.addEventListener('click', togglePasswordVisibility); - - diff --git a/ui/src/style.css b/ui/src/style.css deleted file mode 100644 index 2e4621d15..000000000 --- a/ui/src/style.css +++ /dev/null @@ -1,125 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - --color-etherpad: #0f775b; -} - -body { - font-size: 16px; - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -#app { - max-width: 1280px; - margin: auto; - padding: 2rem; -} - - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} - -button:hover { - border-color: #646cff; -} - -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - - a:hover { - color: #747bff; - } - - button { - background-color: #f9f9f9; - } -} - -.login-box { - background-color: #f2f6f7; - padding: 40px; - border-radius: 20px; - color: #607278; -} - -body { - background: radial-gradient(100% 100% at 50% 0%, var(--color-etherpad) 0%, #003A47 100%) fixed -} - -input { - border-radius: 8px; - border: 1px solid #d1d1d1; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #f9f9f9; - transition: border-color 0.25s; -} - -.login-inner-box { - display: flex; - flex-direction: column; - gap: 10px; -} - -.login-inner-box input[type=submit] { - background-color: var(--color-etherpad); - color: white; - border: none; - cursor: pointer; - margin-top: 20px; -} - -.password-label { - position: relative; -} - -.password-label svg { - position: absolute; - right: 10px; - top: 50%; - transform: translateY(-50%); - cursor: pointer; - width: 16px; -} - -#eye-hide { - display: none; -} - -label { - display: flex; -} - -label input { - flex-grow: 1; -} diff --git a/ui/src/typescript.svg b/ui/src/typescript.svg deleted file mode 100644 index d91c910cc..000000000 --- a/ui/src/typescript.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a..000000000 --- a/ui/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/ui/tsconfig.json b/ui/tsconfig.json deleted file mode 100644 index 75abdef26..000000000 --- a/ui/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"] -} diff --git a/ui/vite.config.ts b/ui/vite.config.ts deleted file mode 100644 index 89667286b..000000000 --- a/ui/vite.config.ts +++ /dev/null @@ -1,47 +0,0 @@ -// vite.config.js -import { resolve } from 'path' -import { defineConfig } from 'vite' - -export default defineConfig({ - base: '/views/', - build: { - commonjsOptions:{ - transformMixedEsModules: true, - }, - outDir: resolve(__dirname, '../src/static/oidc'), - rollupOptions: { - input: { - main: resolve(__dirname, 'consent.html'), - nested: resolve(__dirname, 'login.html'), - }, - }, - emptyOutDir: true, - }, - server:{ - proxy:{ - '/static':{ - target: 'http://localhost:9001', - changeOrigin: true, - secure: false, - }, - '/views/manifest.json':{ - target: 'http://localhost:9001', - changeOrigin: true, - secure: false, - rewrite: (path) => path.replace(/^\/views/, ''), - }, - '/locales.json':{ - target: 'http://localhost:9001', - changeOrigin: true, - secure: false, - rewrite: (path) => path.replace(/^\/views/, ''), - }, - '/locales':{ - target: 'http://localhost:9001', - changeOrigin: true, - secure: false, - rewrite: (path) => path.replace(/^\/views/, ''), - }, - } - } -}) diff --git a/var/.gitignore b/var/.gitignore index d75cb9e42..91f8c0959 100644 --- a/var/.gitignore +++ b/var/.gitignore @@ -1,5 +1,2 @@ sqlite.db minified* -installed_plugins.json -dirty.db -rusty.db