diff --git a/.dockerignore b/.dockerignore index d8d3a3ebe..f7accabfd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,3 +24,4 @@ Dockerfile settings.json src/node_modules +admin/node_modules diff --git a/.env.default b/.env.default new file mode 100644 index 000000000..b78b5599a --- /dev/null +++ b/.env.default @@ -0,0 +1,18 @@ +# 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/.github/dependabot.yml b/.github/dependabot.yml index 58200fd3c..263f5a783 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,8 +14,3 @@ updates: schedule: interval: "daily" versioning-strategy: "increase" - - package-ecosystem: "npm" - directory: "/src/bin/doc" - schedule: - interval: "daily" - versioning-strategy: "increase" diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 000d98ef4..0dd1000d8 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -27,22 +27,42 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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 - run: | - sudo add-apt-repository -y ppa:libreoffice/ppa - sudo apt update - sudo apt install -y --no-install-recommends libreoffice libreoffice-pdfimport + uses: awalsh128/cache-apt-pkgs-action@v1.4.2 + with: + packages: libreoffice libreoffice-pdfimport + version: 1.0 - name: Install all dependencies and symlink for ep_etherpad-lite - run: src/bin/installDeps.sh + 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: cd src && npm test + run: pnpm test withpluginsLinux: # run on pushes to any branch @@ -64,22 +84,43 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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 - run: | - sudo add-apt-repository -y ppa:libreoffice/ppa - sudo apt update - sudo apt install -y --no-install-recommends libreoffice libreoffice-pdfimport + uses: awalsh128/cache-apt-pkgs-action@v1.4.2 + 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 - # The --legacy-peer-deps flag is required to work around a bug in npm v7: - # https://github.com/npm/cli/issues/2199 run: > - npm install --no-save --legacy-peer-deps + pnpm install --workspace-root ep_align ep_author_hover ep_cursortrace @@ -93,21 +134,9 @@ jobs: 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: src/bin/installDeps.sh - name: Run the backend tests - run: cd src && npm test + run: pnpm test withoutpluginsWindows: # run on pushes to any branch @@ -125,13 +154,33 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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: src/bin/installOnWindows.bat + 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: | @@ -139,7 +188,7 @@ jobs: powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json" - name: Run the backend tests - run: cd src && npm test + run: cd src && pnpm test withpluginsWindows: # run on pushes to any branch @@ -157,17 +206,37 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + node-version: 21 + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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: > - npm install --no-save --legacy-peer-deps + pnpm install --workspace-root ep_align ep_author_hover ep_cursortrace @@ -192,7 +261,7 @@ jobs: # rules. - name: Install all dependencies and symlink for ep_etherpad-lite - run: src/bin/installOnWindows.bat + run: bin/installOnWindows.bat - name: Fix up the settings.json run: | @@ -200,4 +269,4 @@ jobs: powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json" - name: Run the backend tests - run: cd src && npm test + run: cd src && pnpm test diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dde0d68d5..7e728ec64 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -30,6 +30,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . + target: production load: true tags: ${{ env.TEST_TAG }} cache-from: type=gha @@ -39,16 +40,28 @@ jobs: uses: actions/setup-node@v4 with: node-version: 'lts/*' - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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 run: | docker run --rm -d -p 9001:9001 --name test ${{ env.TEST_TAG }} + ./bin/installDeps.sh docker logs -f test & - ./src/bin/installDeps.sh while true; do echo "Waiting for Docker container to start..." status=$(docker container inspect -f '{{.State.Health.Status}}' test) || exit 1 @@ -58,7 +71,7 @@ jobs: *) printf %s\\n "unexpected status: ${status}" >&2; exit 1;; esac done - (cd src && npm run test-container) + (cd src && pnpm run test-container) git clean -dxf . - name: Docker meta @@ -85,6 +98,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . + target: production platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} @@ -96,4 +110,4 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} repository: etherpad/etherpad - enable-url-completion: true \ No newline at end of file + enable-url-completion: true diff --git a/.github/workflows/frontend-admin-tests.yml b/.github/workflows/frontend-admin-tests.yml index f3876816b..cb89d2b91 100644 --- a/.github/workflows/frontend-admin-tests.yml +++ b/.github/workflows/frontend-admin-tests.yml @@ -12,11 +12,10 @@ jobs: name: with plugins runs-on: ubuntu-latest -# node: [16, 19, 20] >> Disabled node 16 and 18 because they do not work strategy: fail-fast: false matrix: - node: [19, 20, 21] + node: [20, 21] steps: - @@ -32,16 +31,30 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json - - - 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: npm install --no-save --legacy-peer-deps ep_align@0.2.27 + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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 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 @@ -53,10 +66,10 @@ jobs: # rules. - name: Install all dependencies and symlink for ep_etherpad-lite - run: src/bin/installDeps.sh - - - name: Install etherpad plugins - run: rm -Rf node_modules/ep_align/static/tests/* + run: bin/installDeps.sh + #- + # name: Install etherpad plugins + # run: rm -Rf node_modules/ep_align/static/tests/* - name: export GIT_HASH to env id: environment @@ -66,31 +79,67 @@ jobs: 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\":\"changeme\",\"is_admin\":true}}/' settings.json" + 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\": 10000/\"maxHttpBufferSize\": 100000/' settings.json" + run: "sed -i 's/\"maxHttpBufferSize\": 10000/\"maxHttpBufferSize\": 10000000/' settings.json" - name: Disable import/export rate limiting run: | - sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 1000000/' -i 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 - - - 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 }} + sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json + - name: Build admin frontend + working-directory: admin run: | - src/tests/frontend/travis/adminrunner.sh + pnpm install + 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 dev & + 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 diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index e1524d732..93018ea0a 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -7,10 +7,141 @@ permissions: contents: read # to fetch code (actions/checkout) jobs: - withoutplugins: - name: without plugins + 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: 21 + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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 dev & + 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: 21 + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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 dev & + 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 runs-on: ubuntu-latest - if: ${{ github.actor != 'dependabot[bot]' }} steps: - @@ -25,127 +156,59 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + node-version: 21 + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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: src/bin/installDeps.sh + 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 settings.json.template settings.json - - - name: Disable import/export rate limiting - run: | - sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json - - - 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 tests + run: cp ./src/tests/settings.json settings.json + - name: Run the frontend 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/runner.sh - - withplugins: - name: with plugins - runs-on: ubuntu-latest - if: ${{ github.actor != 'dependabot[bot]' }} - - 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 + pnpm run dev & + 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: - node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json - - - 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: > - npm install --no-save --legacy-peer-deps - ep_align - ep_author_hover - ep_cursortrace - ep_embedmedia - 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 - # 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.20.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: Create settings.json - run: cp settings.json.template settings.json - - - name: Disable import/export rate limiting - run: | - sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 1000000/' -i 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/* - - - 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 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/runner.sh + name: playwright-report-${{ matrix.node }}-webkit + path: src/playwright-report/ + retention-days: 30 + + + diff --git a/.github/workflows/lint-package-lock.yml b/.github/workflows/lint-package-lock.yml index eae4221a9..f1073172a 100644 --- a/.github/workflows/lint-package-lock.yml +++ b/.github/workflows/lint-package-lock.yml @@ -23,13 +23,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json - - - name: Install lockfile-lint - run: npm install --no-save lockfile-lint --legacy-peer-deps - name: Run lockfile-lint on package-lock.json run: > diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 9aa87eec4..d4f1a9b44 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -23,16 +23,30 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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: src/bin/installDeps.sh + run: bin/installDeps.sh - name: Install etherpad-load-test - run: sudo npm install -g 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 @@ -53,19 +67,33 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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: sudo npm install -g 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: > - npm install --no-save --legacy-peer-deps + pnpm install --workspace-root ep_align ep_author_hover ep_cursortrace @@ -89,7 +117,7 @@ jobs: # rules. - name: Install all dependencies and symlink for ep_etherpad-lite - run: src/bin/installDeps.sh + run: bin/installDeps.sh - name: Run load test run: src/tests/frontend/travis/runnerLoadTest.sh 25 50 @@ -110,16 +138,30 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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: src/bin/installDeps.sh + run: bin/installDeps.sh - name: Install etherpad-load-test - run: sudo npm install -g 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 diff --git a/.github/workflows/perform-type-check.yml b/.github/workflows/perform-type-check.yml new file mode 100644 index 000000000..81be2567d --- /dev/null +++ b/.github/workflows/perform-type-check.yml @@ -0,0 +1,46 @@ +name: "Perform type checks" + +# any branch is useful for testing before a PR is submitted +on: [push, pull_request] + +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@v3 + name: Install pnpm + with: + version: 8 + 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 1e878dc44..dab99fff3 100644 --- a/.github/workflows/rate-limit.yml +++ b/.github/workflows/rate-limit.yml @@ -23,17 +23,30 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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: 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 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 . - @@ -44,7 +57,7 @@ jobs: 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 + run: bin/installDeps.sh - name: run rate limit test run: | diff --git a/.github/workflows/upgrade-from-latest-release.yml b/.github/workflows/upgrade-from-latest-release.yml index cdd6d20b3..f2c713c47 100644 --- a/.github/workflows/upgrade-from-latest-release.yml +++ b/.github/workflows/upgrade-from-latest-release.yml @@ -29,14 +29,11 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json - 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 + # Important: Installer for old master which does not have pnpm right now + # The --legacy-peer-deps flag is required to work around a bug in npm v7: + # https://github.com/npm/cli/issues/2199 run: > npm install --no-save --legacy-peer-deps ep_align @@ -52,15 +49,6 @@ jobs: 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: src/bin/installDeps.sh @@ -82,18 +70,44 @@ jobs: # 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}" + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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.4.2 + 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: Install all dependencies and symlink for ep_etherpad-lite - run: src/bin/installDeps.sh + run: bin/installDeps.sh - name: Run the backend tests - run: cd src && npm test + run: pnpm test - name: Install Cypress - run: cd src && npm install cypress --legacy-peer-deps + working-directory: ./src + run: pnpm install cypress - name: Run Etherpad & Test Frontend + working-directory: ./src run: | - node src/node/server.js & + pnpm run dev & 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 - ./src/node_modules/cypress/bin/cypress run --config-file src/tests/frontend/cypress/cypress.config.js + ./node_modules/cypress/bin/cypress run --config-file tests/frontend/cypress/cypress.config.js diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 8dbd03339..99a779234 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -28,23 +28,37 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 - cache: 'npm' - cache-dependency-path: | - src/package-lock.json - src/bin/doc/package-lock.json + node-version: 21 + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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: src/bin/installDeps.sh + run: bin/installDeps.sh - name: Run the backend tests shell: msys2 {0} - run: cd src && npm test + run: cd src && pnpm test - name: Build the .zip shell: msys2 {0} - run: src/bin/buildForWindows.sh + run: bin/buildForWindows.sh - name: Archive production artifacts uses: actions/upload-artifact@v4 @@ -75,9 +89,9 @@ jobs: run: 7z x etherpad-win.zip -oetherpad-zip - name: Create installer - uses: joncloud/makensis-action@v3.7 + uses: joncloud/makensis-action@v4.1 with: - script-file: 'src/bin/nsis/etherpad.nsi' + script-file: 'bin/nsis/etherpad.nsi' - name: Archive production artifacts uses: actions/upload-artifact@v4 @@ -109,27 +123,43 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' - cache-dependency-path: | - etherpad/src/package-lock.json - etherpad/src/bin/doc/package-lock.json - - - name: Install Cypress - run: cd etherpad && cd src && npm install cypress --legacy-peer-deps + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 8 + 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 + working-directory: etherpad - name: Run Etherpad + working-directory: etherpad/src run: | - cd etherpad - node node_modules\ep_etherpad-lite\node\server.js & + pnpm install cypress + .\node_modules\.bin\cypress.cmd install --force + 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 - src\node_modules\cypress\bin\cypress run --config-file src\tests\frontendcypress\cypress.config.js + pnpm exec cypress run --config-file ./tests/frontend/cypress/cypress.config.js # On release, upload windows zip to GitHub release tab - name: Rename to etherpad-lite-win.zip shell: powershell run: mv etherpad-win.zip etherpad-lite-win.zip - name: upload binaries to release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: ${{startsWith(github.ref, 'refs/tags/v') }} with: files: etherpad-lite-win.zip diff --git a/.gitignore b/.gitignore index 6c94cbad2..f577330c9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules APIKEY.txt SESSIONKEY.txt var/dirty.db +.env *~ *.patch npm-debug.log @@ -21,4 +22,8 @@ out/ /src/bin/convertSettings.json /src/bin/etherpad-1.deb /src/bin/node.exe -plugin_packages \ No newline at end of file +plugin_packages +/src/templates/admin +/src/test-results +playwright-report +state.json diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..f301fedf9 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +auto-install-peers=false diff --git a/.travis.yml b/.travis.yml index 44a8693bb..ca8c5380f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -55,7 +55,7 @@ jobs: - *set_loglevel_warn - *enable_admin_tests - "src/tests/frontend/travis/sauce_tunnel.sh" - - "src/bin/installDeps.sh" + - "bin/installDeps.sh" - "export GIT_HASH=$(git rev-parse --verify --short HEAD)" script: - "./src/tests/frontend/travis/runner.sh" @@ -63,22 +63,22 @@ jobs: install: - *install_libreoffice - *set_loglevel_warn - - "src/bin/installDeps.sh" - - "cd src && npm install && cd -" + - "bin/installDeps.sh" + - "cd src && pnpm install && cd -" script: - - "cd src && npm test" + - "cd src && pnpm test" - name: "Test the Dockerfile" install: - - "cd src && npm install && cd -" + - "cd src && pnpm install && cd -" script: - "docker build -t etherpad:test ." - "docker run -d -p 9001:9001 etherpad:test && sleep 3" - - "cd src && npm run test-container" + - "cd src && pnpm run test-container" - name: "Load test Etherpad without Plugins" install: - *set_loglevel_warn - - "src/bin/installDeps.sh" - - "cd src && npm install && cd -" + - "bin/installDeps.sh" + - "cd src && pnpm install && cd -" - "npm install -g etherpad-load-test" script: - "src/tests/frontend/travis/runnerLoadTest.sh" @@ -90,7 +90,7 @@ jobs: - *set_loglevel_warn - *enable_admin_tests - "src/tests/frontend/travis/sauce_tunnel.sh" - - "src/bin/installDeps.sh" + - "bin/installDeps.sh" - "rm src/tests/frontend/specs/*" - *install_plugins - "export GIT_HASH=$(git rev-parse --verify --short HEAD)" @@ -105,22 +105,22 @@ jobs: install: - *install_libreoffice - *set_loglevel_warn - - "src/bin/installDeps.sh" + - "bin/installDeps.sh" - *install_plugins - - "cd src && npm install && cd -" + - "cd src && pnpm install && cd -" script: - - "cd src && npm test" + - "cd src && pnpm test" - name: "Test the Dockerfile" install: - - "cd src && npm install && cd -" + - "cd src && pnpm install && cd -" script: - "docker build -t etherpad:test ." - "docker run -d -p 9001:9001 etherpad:test && sleep 3" - - "cd src && npm run test-container" + - "cd src && pnpm run test-container" - name: "Load test Etherpad with Plugins" install: - *set_loglevel_warn - - "src/bin/installDeps.sh" + - "bin/installDeps.sh" - *install_plugins - "cd src && npm install && cd -" - "npm install -g etherpad-load-test" @@ -135,7 +135,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" - - "./src/bin/installDeps.sh" + - "./bin/installDeps.sh" script: - "cd src/tests/ratelimit && bash testlimits.sh" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8377712..1b5c29985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +# 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 install-plugins first-plugin second-plugin` or if you want to install from path you can do: + `pnpm run install-plugins --path ../path-to-plugin` + + # 1.9.7 ### Notable enhancements and fixes diff --git a/Dockerfile b/Dockerfile index 0f42ab832..5bfeca4f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,21 @@ # # Author: muxator -FROM node:lts-alpine +FROM node:alpine as adminBuild + +WORKDIR /opt/etherpad-lite +COPY ./admin ./admin +RUN cd ./admin && npm install -g pnpm && pnpm install && pnpm run build --outDir ./dist + + +FROM node:alpine as build LABEL maintainer="Etherpad team, https://github.com/ether/etherpad-lite" +# Set these arguments when building the image from behind a proxy +ARG http_proxy= +ARG https_proxy= +ARG no_proxy= + ARG TIMEZONE= RUN \ @@ -44,11 +56,6 @@ ARG INSTALL_ABIWORD= # INSTALL_LIBREOFFICE=true ARG INSTALL_SOFFICE= -# By default, Etherpad container is built and run in "production" mode. This is -# leaner (development dependencies are not installed) and runs faster (among -# other things, assets are minified & compressed). -ENV NODE_ENV=production -ENV ETHERPAD_PRODUCTION=true # Install dependencies required for modifying access. RUN apk add shadow bash # Follow the principle of least privilege: run as unprivileged user. @@ -63,8 +70,6 @@ ARG EP_UID=5001 ARG EP_GID=0 ARG EP_SHELL= -ENV NODE_ENV=production - RUN groupadd --system ${EP_GID:+--gid "${EP_GID}" --non-unique} etherpad && \ useradd --system ${EP_UID:+--uid "${EP_UID}" --non-unique} --gid etherpad \ ${EP_HOME:+--home-dir "${EP_HOME}"} --create-home \ @@ -77,10 +82,11 @@ RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}" # https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199 RUN \ mkdir -p /usr/share/man/man1 && \ - npm install npm@6 -g && \ + npm install pnpm -g && \ apk update && apk upgrade && \ apk add \ ca-certificates \ + curl \ git \ ${INSTALL_ABIWORD:+abiword abiword-plugin-command} \ ${INSTALL_SOFFICE:+libreoffice openjdk8-jre libreoffice-common} @@ -89,32 +95,45 @@ USER etherpad WORKDIR "${EP_DIR}" -COPY --chown=etherpad:etherpad ./ ./ +# etherpads version feature requires this. Only copy what is really needed +COPY --chown=etherpad:etherpad ./.git/HEAD ./.git/HEAD +COPY --chown=etherpad:etherpad ./.git/refs ./.git/refs +COPY --chown=etherpad:etherpad ${SETTINGS} ./settings.json +COPY --chown=etherpad:etherpad ./var ./var +COPY --chown=etherpad:etherpad ./bin ./bin +COPY --chown=etherpad:etherpad ./pnpm-workspace.yaml ./package.json ./ + +FROM build as development + +COPY --chown=etherpad:etherpad ./src/package.json .npmrc ./src/pnpm-lock.yaml ./src/ +COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin + +RUN bin/installDeps.sh && \ + { [ -z "${ETHERPAD_PLUGINS}" ] || pnpm run install-plugins ${ETHERPAD_PLUGINS}; } + +FROM build as production + +ENV NODE_ENV=production +ENV ETHERPAD_PRODUCTION=true + +COPY --chown=etherpad:etherpad ./src ./src +COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin + +RUN bin/installDeps.sh && rm -rf ~/.npm && \ + { [ -z "${ETHERPAD_PLUGINS}" ] || pnpm run install-plugins ${ETHERPAD_PLUGINS}; } -# Plugins must be installed before installing Etherpad'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. -RUN { [ -z "${ETHERPAD_PLUGINS}" ] || \ - npm install --no-save --legacy-peer-deps ${ETHERPAD_PLUGINS}; } && \ - src/bin/installDeps.sh && \ - rm -rf ~/.npm # Copy the configuration file. COPY --chown=etherpad:etherpad ${SETTINGS} "${EP_DIR}"/settings.json # Fix group permissions -RUN chmod -R g=u . +# Note: For some reason increases image size from 257 to 334. +# RUN chmod -R g=u . -USER root -RUN cd src && npm link USER etherpad -HEALTHCHECK --interval=20s --timeout=3s CMD ["etherpad-healthcheck"] +HEALTHCHECK --interval=5s --timeout=3s \ + CMD curl --silent http://localhost:9001/health | grep -E "pass|ok|up" > /dev/null || exit 1 EXPOSE 9001 -CMD ["etherpad"] +CMD ["pnpm", "run", "prod"] diff --git a/README.md b/README.md index 41da517af..b31d1f1a2 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Install the latest Node.js LTS per [official install instructions](https://githu ```sh git clone --branch master https://github.com/ether/etherpad-lite.git && cd etherpad-lite && -src/bin/run.sh +bin/run.sh ``` #### Manual install @@ -70,10 +70,10 @@ You'll need Git and [Node.js](https://nodejs.org/) installed. https://github.com/ether/etherpad-lite.git` 3. Change into the new directory containing the cloned source code: `cd etherpad-lite` - 4. Run `src/bin/run.sh` and open http://127.0.0.1:9001 in your browser. + 4. Run `bin/run.sh` and open http://127.0.0.1:9001 in your browser. To update to the latest released version, execute `git pull origin`. The next -start with `src/bin/run.sh` will update the dependencies. +start with `bin/run.sh` will update the dependencies. ### Windows @@ -98,17 +98,17 @@ git. * or `git clone --branch master https://github.com/ether/etherpad-lite.git` 2. With a "Run as administrator" command prompt execute - `src\bin\installOnWindows.bat` + `bin\installOnWindows.bat` Now, run `start.bat` and open http://localhost:9001 in your browser. Update to the latest version with `git pull origin`, then run -`src\bin\installOnWindows.bat`, again. +`bin\installOnWindows.bat`, again. If cloning to a subdirectory within another project, you may need to do the following: - 1. Start the server manually (e.g. `node src/node/server.js`) + 1. Start the server manually (e.g. `node src/node/server.ts`) 2. Edit the db `filename` in `settings.json` to the relative directory with the file (e.g. `application/lib/etherpad-lite/var/dirty.db`) 3. Add auto-generated files to the main project `.gitignore` @@ -139,9 +139,7 @@ Alternatively, you can install plugins from the command line: ```sh cd /path/to/etherpad-lite -# The `--no-save` and `--legacy-peer-deps` arguments are necessary to work -# around npm quirks. -npm install --no-save --legacy-peer-deps ep_${plugin_name} +pnpm run install-plugins ep_${plugin_name} ``` Also see [the plugin wiki @@ -153,7 +151,7 @@ Run the following command in your Etherpad folder to get all of the features visible in the above demo gif: ```sh -npm install --no-save --legacy-peer-deps \ +pnpm run install-plugins \ ep_align \ ep_comments_page \ ep_embedded_hyperlinks2 \ @@ -182,7 +180,7 @@ following plugins: ### Tweak the settings You can modify the settings in `settings.json`. If you need to handle multiple -settings files, you can pass the path to a settings file to `src/bin/run.sh` +settings files, you can pass the path to a settings file to `bin/run.sh` using the `-s|--settings` option: this allows you to run multiple Etherpad instances from the same installation. Similarly, `--credentials` can be used to give a settings override file, `--apikey` to give a different APIKEY.txt file @@ -226,11 +224,11 @@ Documentation can be found in `doc/`. ### Things you should know -You can debug Etherpad using `src/bin/debugRun.sh`. +You can debug Etherpad using `bin/debugRun.sh`. -You can run Etherpad quickly launching `src/bin/fastRun.sh`. It's convenient for +You can run Etherpad quickly launching `bin/fastRun.sh`. It's convenient for developers and advanced users. Be aware that it will skip the dependencies -update, so remember to run `src/bin/installDeps.sh` after installing a new +update, so remember to run `bin/installDeps.sh` after installing a new dependency or upgrading version. If you want to find out how Etherpad's `Easysync` works (the library that makes diff --git a/admin/.eslintrc.cjs b/admin/.eslintrc.cjs new file mode 100644 index 000000000..d6c953795 --- /dev/null +++ b/admin/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,24 @@ +# 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/admin/README.md b/admin/README.md new file mode 100644 index 000000000..0d6babedd --- /dev/null +++ b/admin/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 000000000..8863894ed --- /dev/null +++ b/admin/index.html @@ -0,0 +1,14 @@ + + + + + + Etherpad Admin Dashboard + + + +
+
+ + + diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 000000000..9a92b4e26 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,39 @@ +{ + "name": "admin", + "private": true, + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": {}, + "devDependencies": { + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-toast": "^1.1.5", + "i18next": "^23.10.1", + "i18next-browser-languagedetector": "^7.2.0", + "lucide-react": "^0.356.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.51.0", + "react-i18next": "^14.1.0", + "react-router-dom": "^6.22.3", + "zustand": "^4.5.2", + "@types/react": "^18.2.56", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.2", + "@vitejs/plugin-react-swc": "^3.5.0", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "socket.io-client": "^4.7.4", + "typescript": "^5.2.2", + "vite": "^5.1.4", + "vite-plugin-static-copy": "^1.0.1", + "vite-plugin-svgr": "^4.2.0" + } +} diff --git a/admin/public/Karla-Bold.ttf b/admin/public/Karla-Bold.ttf new file mode 100644 index 000000000..2348e0072 Binary files /dev/null and b/admin/public/Karla-Bold.ttf differ diff --git a/admin/public/Karla-BoldItalic.ttf b/admin/public/Karla-BoldItalic.ttf new file mode 100644 index 000000000..3c0e045ec Binary files /dev/null and b/admin/public/Karla-BoldItalic.ttf differ diff --git a/admin/public/Karla-ExtraBold.ttf b/admin/public/Karla-ExtraBold.ttf new file mode 100644 index 000000000..f18471195 Binary files /dev/null and b/admin/public/Karla-ExtraBold.ttf differ diff --git a/admin/public/Karla-ExtraBoldItalic.ttf b/admin/public/Karla-ExtraBoldItalic.ttf new file mode 100644 index 000000000..3799659c0 Binary files /dev/null and b/admin/public/Karla-ExtraBoldItalic.ttf differ diff --git a/admin/public/Karla-ExtraLight.ttf b/admin/public/Karla-ExtraLight.ttf new file mode 100644 index 000000000..0f8642c02 Binary files /dev/null and b/admin/public/Karla-ExtraLight.ttf differ diff --git a/admin/public/Karla-ExtraLightItalic.ttf b/admin/public/Karla-ExtraLightItalic.ttf new file mode 100644 index 000000000..bb328e175 Binary files /dev/null and b/admin/public/Karla-ExtraLightItalic.ttf differ diff --git a/admin/public/Karla-Italic.ttf b/admin/public/Karla-Italic.ttf new file mode 100644 index 000000000..1853cbe4e Binary files /dev/null and b/admin/public/Karla-Italic.ttf differ diff --git a/admin/public/Karla-Light.ttf b/admin/public/Karla-Light.ttf new file mode 100644 index 000000000..46457ece7 Binary files /dev/null and b/admin/public/Karla-Light.ttf differ diff --git a/admin/public/Karla-LightItalic.ttf b/admin/public/Karla-LightItalic.ttf new file mode 100644 index 000000000..3b0f01ff1 Binary files /dev/null and b/admin/public/Karla-LightItalic.ttf differ diff --git a/admin/public/Karla-Medium.ttf b/admin/public/Karla-Medium.ttf new file mode 100644 index 000000000..9066b49c4 Binary files /dev/null and b/admin/public/Karla-Medium.ttf differ diff --git a/admin/public/Karla-MediumItalic.ttf b/admin/public/Karla-MediumItalic.ttf new file mode 100644 index 000000000..ea9535355 Binary files /dev/null and b/admin/public/Karla-MediumItalic.ttf differ diff --git a/admin/public/Karla-Regular.ttf b/admin/public/Karla-Regular.ttf new file mode 100644 index 000000000..c164d0047 Binary files /dev/null and b/admin/public/Karla-Regular.ttf differ diff --git a/admin/public/Karla-SemiBold.ttf b/admin/public/Karla-SemiBold.ttf new file mode 100644 index 000000000..82e0c5abf Binary files /dev/null and b/admin/public/Karla-SemiBold.ttf differ diff --git a/admin/public/Karla-SemiBoldItalic.ttf b/admin/public/Karla-SemiBoldItalic.ttf new file mode 100644 index 000000000..77c19ef69 Binary files /dev/null and b/admin/public/Karla-SemiBoldItalic.ttf differ diff --git a/admin/public/ep_admin_pads/ar.json b/admin/public/ep_admin_pads/ar.json new file mode 100644 index 000000000..746946edf --- /dev/null +++ b/admin/public/ep_admin_pads/ar.json @@ -0,0 +1,28 @@ +{ + "@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 new file mode 100644 index 000000000..0048b52bb --- /dev/null +++ b/admin/public/ep_admin_pads/bn.json @@ -0,0 +1,23 @@ +{ + "@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 new file mode 100644 index 000000000..1d4e34216 --- /dev/null +++ b/admin/public/ep_admin_pads/ca.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..19e92894d --- /dev/null +++ b/admin/public/ep_admin_pads/cs.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..02546da90 --- /dev/null +++ b/admin/public/ep_admin_pads/cy.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..a5303b9cb --- /dev/null +++ b/admin/public/ep_admin_pads/da.json @@ -0,0 +1,14 @@ +{ + "@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 new file mode 100644 index 000000000..afb553caf --- /dev/null +++ b/admin/public/ep_admin_pads/de.json @@ -0,0 +1,32 @@ +{ + "@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_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 new file mode 100644 index 000000000..983680965 --- /dev/null +++ b/admin/public/ep_admin_pads/diq.json @@ -0,0 +1,28 @@ +{ + "@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 new file mode 100644 index 000000000..363732a20 --- /dev/null +++ b/admin/public/ep_admin_pads/dsb.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..77b6af3dd --- /dev/null +++ b/admin/public/ep_admin_pads/el.json @@ -0,0 +1,16 @@ +{ + "@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 new file mode 100644 index 000000000..8a9044b1b --- /dev/null +++ b/admin/public/ep_admin_pads/en.json @@ -0,0 +1,22 @@ +{ + "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_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 new file mode 100644 index 000000000..71d9dfe79 --- /dev/null +++ b/admin/public/ep_admin_pads/eu.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..8cb5aea99 --- /dev/null +++ b/admin/public/ep_admin_pads/ff.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..708b2bef8 --- /dev/null +++ b/admin/public/ep_admin_pads/fi.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..e6c8a8703 --- /dev/null +++ b/admin/public/ep_admin_pads/fr.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..5e6b66549 --- /dev/null +++ b/admin/public/ep_admin_pads/gl.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..8b506946b --- /dev/null +++ b/admin/public/ep_admin_pads/he.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..a6c29611f --- /dev/null +++ b/admin/public/ep_admin_pads/hsb.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..9210761bc --- /dev/null +++ b/admin/public/ep_admin_pads/hu.json @@ -0,0 +1,25 @@ +{ + "@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 new file mode 100644 index 000000000..f0e00e5ca --- /dev/null +++ b/admin/public/ep_admin_pads/ia.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..493cbb4d5 --- /dev/null +++ b/admin/public/ep_admin_pads/it.json @@ -0,0 +1,16 @@ +{ + "@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 new file mode 100644 index 000000000..1e9019611 --- /dev/null +++ b/admin/public/ep_admin_pads/kn.json @@ -0,0 +1,13 @@ +{ + "@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 new file mode 100644 index 000000000..9ab8feed3 --- /dev/null +++ b/admin/public/ep_admin_pads/ko.json @@ -0,0 +1,28 @@ +{ + "@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 new file mode 100644 index 000000000..2caf4f099 --- /dev/null +++ b/admin/public/ep_admin_pads/krc.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..61aa2588d --- /dev/null +++ b/admin/public/ep_admin_pads/lb.json @@ -0,0 +1,16 @@ +{ + "@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 new file mode 100644 index 000000000..59b2a13b3 --- /dev/null +++ b/admin/public/ep_admin_pads/lt.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..72affd86c --- /dev/null +++ b/admin/public/ep_admin_pads/mk.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..6b94ba702 --- /dev/null +++ b/admin/public/ep_admin_pads/my.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..acd194397 --- /dev/null +++ b/admin/public/ep_admin_pads/nb.json @@ -0,0 +1,13 @@ +{ + "@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 new file mode 100644 index 000000000..f4d97b351 --- /dev/null +++ b/admin/public/ep_admin_pads/nl.json @@ -0,0 +1,29 @@ +{ + "@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 new file mode 100644 index 000000000..ae0169faf --- /dev/null +++ b/admin/public/ep_admin_pads/oc.json @@ -0,0 +1,21 @@ +{ + "@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 new file mode 100644 index 000000000..ac0542b85 --- /dev/null +++ b/admin/public/ep_admin_pads/pms.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..28a7874ee --- /dev/null +++ b/admin/public/ep_admin_pads/pt-br.json @@ -0,0 +1,30 @@ +{ + "@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 new file mode 100644 index 000000000..b7abf2f3f --- /dev/null +++ b/admin/public/ep_admin_pads/pt.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..de36e2ae6 --- /dev/null +++ b/admin/public/ep_admin_pads/qqq.json @@ -0,0 +1,10 @@ +{ + "@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 new file mode 100644 index 000000000..6d0d163d0 --- /dev/null +++ b/admin/public/ep_admin_pads/ru.json @@ -0,0 +1,31 @@ +{ + "@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 new file mode 100644 index 000000000..a37bba5a2 --- /dev/null +++ b/admin/public/ep_admin_pads/sc.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..c4672fd7f --- /dev/null +++ b/admin/public/ep_admin_pads/sdc.json @@ -0,0 +1,14 @@ +{ + "@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 new file mode 100644 index 000000000..ab0392d4e --- /dev/null +++ b/admin/public/ep_admin_pads/sk.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..08162f849 --- /dev/null +++ b/admin/public/ep_admin_pads/skr-arab.json @@ -0,0 +1,20 @@ +{ + "@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 new file mode 100644 index 000000000..3bebe1972 --- /dev/null +++ b/admin/public/ep_admin_pads/sl.json @@ -0,0 +1,28 @@ +{ + "@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 new file mode 100644 index 000000000..9d57cc73c --- /dev/null +++ b/admin/public/ep_admin_pads/smn.json @@ -0,0 +1,13 @@ +{ + "@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 new file mode 100644 index 000000000..8d3cf5797 --- /dev/null +++ b/admin/public/ep_admin_pads/sms.json @@ -0,0 +1,16 @@ +{ + "@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 new file mode 100644 index 000000000..cc4740763 --- /dev/null +++ b/admin/public/ep_admin_pads/sq.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..e77aaf2c4 --- /dev/null +++ b/admin/public/ep_admin_pads/sv.json @@ -0,0 +1,28 @@ +{ + "@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 new file mode 100644 index 000000000..f1beeecbb --- /dev/null +++ b/admin/public/ep_admin_pads/sw.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..693e3f797 --- /dev/null +++ b/admin/public/ep_admin_pads/th.json @@ -0,0 +1,27 @@ +{ + "@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 new file mode 100644 index 000000000..238e01236 --- /dev/null +++ b/admin/public/ep_admin_pads/tl.json @@ -0,0 +1,17 @@ +{ + "@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 new file mode 100644 index 000000000..7e2e9d402 --- /dev/null +++ b/admin/public/ep_admin_pads/tr.json @@ -0,0 +1,28 @@ +{ + "@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 new file mode 100644 index 000000000..c5c95f722 --- /dev/null +++ b/admin/public/ep_admin_pads/uk.json @@ -0,0 +1,28 @@ +{ + "@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 new file mode 100644 index 000000000..cdf0d945f --- /dev/null +++ b/admin/public/ep_admin_pads/zh-hans.json @@ -0,0 +1,29 @@ +{ + "@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 new file mode 100644 index 000000000..daeed55f5 --- /dev/null +++ b/admin/public/ep_admin_pads/zh-hant.json @@ -0,0 +1,28 @@ +{ + "@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 new file mode 100644 index 000000000..81357c7bb Binary files /dev/null and b/admin/public/fond.jpg differ diff --git a/admin/src/App.css b/admin/src/App.css new file mode 100644 index 000000000..e69de29bb diff --git a/admin/src/App.tsx b/admin/src/App.tsx new file mode 100644 index 000000000..a1a1e4377 --- /dev/null +++ b/admin/src/App.tsx @@ -0,0 +1,110 @@ +import {useEffect} 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} 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() + + 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

+
+
    +
  • +
  • +
  • +
  • +
+
+
+
+ +
+
+} + +export default App diff --git a/admin/src/components/IconButton.tsx b/admin/src/components/IconButton.tsx new file mode 100644 index 000000000..e91f3e914 --- /dev/null +++ b/admin/src/components/IconButton.tsx @@ -0,0 +1,16 @@ +import {FC, 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 new file mode 100644 index 000000000..62a965d40 --- /dev/null +++ b/admin/src/components/SearchField.tsx @@ -0,0 +1,14 @@ +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/index.css b/admin/src/index.css new file mode 100644 index 000000000..7a8e5df48 --- /dev/null +++ b/admin/src/index.css @@ -0,0 +1,727 @@ +:root { + --etherpad-color: #0f775b; + --etherpad-comp: #9C8840; + --etherpad-light: #99FF99; +} + +@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 { + height: 100vh; + font-size: 16px; + font-weight: bolder; + display: flex; + align-items: center; + justify-content: center; + max-width: 20%; + min-width: 20%; +} + +.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 { + background-color: #F0F0F0; + overflow: auto; + height: 100vh; + flex-grow: 100; + padding: 20px; +} + +div.innerwrapper-err { + display: none; +} + +#wrapper { + display: flex; + 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; +} + + + + + +#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 { + outline: none; + width: 100%; + min-height: 80vh; + resize: none; +} + +#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; +} + +@media (max-width: 800px) { + div.innerwrapper { + padding: 0 15px 15px 15px; + } + + div.menu { + padding: 1px 15px 0 15px; + position: static; + height: auto; + border-right: none; + width: auto; + 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-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 { + width: 20%; + padding: 20px; + border-radius: 40px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + background-color: #fff; +} + +.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: 100vh; + padding: 5px 5px 5px 30px; +} + +.search-field input:focus { + outline: none; +} + +.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; +} diff --git a/admin/src/localization/i18n.ts b/admin/src/localization/i18n.ts new file mode 100644 index 000000000..67ae140e7 --- /dev/null +++ b/admin/src/localization/i18n.ts @@ -0,0 +1,57 @@ +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 new file mode 100644 index 000000000..03ec73104 --- /dev/null +++ b/admin/src/main.tsx @@ -0,0 +1,40 @@ +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"; + +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 new file mode 100644 index 000000000..6f06907e1 --- /dev/null +++ b/admin/src/pages/HelpPage.tsx @@ -0,0 +1,70 @@ +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 new file mode 100644 index 000000000..0ea1a26d4 --- /dev/null +++ b/admin/src/pages/HomePage.tsx @@ -0,0 +1,188 @@ +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 {Download, Trash} from "lucide-react"; +import {IconButton} from "../components/IconButton.tsx"; + + +export const HomePage = () => { + const pluginsSocket = useStore(state=>state.pluginsSocket) + const [plugins,setPlugins] = useState([]) + const [installedPlugins, setInstalledPlugins] = useState([]) + const sortedInstalledPlugins = useMemo(()=>{ + return installedPlugins.sort((a, b)=>{ + if(a.name < b.name){ + return -1 + } + if(a.name > b.name){ + return 1 + } + return 0 + }) + + } ,[installedPlugins]) + const [searchParams, setSearchParams] = useState({ + offset: 0, + limit: 99999, + sortBy: 'name', + sortDir: 'asc', + searchTerm: '' + }) + 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) => { + data.updatable.forEach((pluginName: string) => { + setInstalledPlugins(installedPlugins.map(plugin => { + if (plugin.name === pluginName) { + return { + ...plugin, + updatable: true + } + } + return plugin + })) + }) + }) + + 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) + }) + + + }, [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 ? + + : } title={} onClick={() => uninstallPlugin(plugin.name)}/> + } +
+ + +

+ {setSearchTerm(v.target.value)}} placeholder={t('admin_plugins.available_search.placeholder')} value={searchTerm}/> + + + + + + + + + + + + + {plugins.map((plugin) => { + return + + + + + + + })} + +
{plugin.name}{plugin.description}{plugin.version}{plugin.time} + } onClick={() => installPlugin(plugin.name)} title={}/> +
+
+} diff --git a/admin/src/pages/LoginScreen.tsx b/admin/src/pages/LoginScreen.tsx new file mode 100644 index 000000000..ade83cd4c --- /dev/null +++ b/admin/src/pages/LoginScreen.tsx @@ -0,0 +1,61 @@ +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
+ +
Passwort
+ + + {passwordVisible? setPasswordVisible(!passwordVisible)}/> : + setPasswordVisible(!passwordVisible)}/>} + + +
+
+
+} diff --git a/admin/src/pages/PadPage.tsx b/admin/src/pages/PadPage.tsx new file mode 100644 index 000000000..b5f2128bf --- /dev/null +++ b/admin/src/pages/PadPage.tsx @@ -0,0 +1,178 @@ +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} 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 [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, pads]); + + const deletePad = (padID: string)=>{ + settingsSocket?.emit('deletePad', padID) + } + + + + return
+ + + +
+
+
+ {t("ep_admin_pads:ep_adminpads2_confirm", { + padID: padToDelete, + })} +
+
+ + +
+
+
+
+
+

+ 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: 'lastEdited', + ascending: !searchParams.ascending + }) + }}>{ + setSearchParams({ + ...searchParams, + sortBy: 'userCount', + 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="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 new file mode 100644 index 000000000..3188c247f --- /dev/null +++ b/admin/src/pages/Plugin.ts @@ -0,0 +1,36 @@ +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', + 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 new file mode 100644 index 000000000..2706ffa38 --- /dev/null +++ b/admin/src/pages/SettingsPage.tsx @@ -0,0 +1,50 @@ +import {useStore} from "../store/store.ts"; +import {isJSONClean} 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 = useStore(state=>state.settings) + + return
+

+ - - -
-
- Example production settings template - Example development settings template -
- -
-

-
- - - - - diff --git a/src/templates/javascript.html b/src/templates/javascript.html index 42482f69d..c501af65c 100644 --- a/src/templates/javascript.html +++ b/src/templates/javascript.html @@ -34,24 +34,16 @@ require-kernel.js - plugins.js Apache-2.0-only - plugins.js - minify.json.js Expat - minify.json.js - settings.js Apache-2.0-only - settings.js - jquery.autosize.js Expat - jquery.autosize.js diff --git a/src/templates/pad.html b/src/templates/pad.html index 54e1897d9..69671ca17 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -442,7 +442,7 @@ <% e.begin_block("scripts"); %> - + diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html index e26cd11e7..ee45f4575 100644 --- a/src/templates/timeslider.html +++ b/src/templates/timeslider.html @@ -250,7 +250,7 @@ - + diff --git a/src/tests/backend/assert-legacy.js b/src/tests/backend/assert-legacy.js deleted file mode 100644 index b3760da9d..000000000 --- a/src/tests/backend/assert-legacy.js +++ /dev/null @@ -1,48 +0,0 @@ -'use strict'; -// support for older node versions (<12) -const assert = require('assert'); - -const internalMatch = (string, regexp, message, fn) => { - if (!regexp.test) { - throw new Error('regexp parameter is not a RegExp'); - } - if (typeof string !== 'string') { - throw new Error('string parameter is not a string'); - } - const match = fn.name === 'match'; - - const result = string.match(regexp); - if (match && !result) { - if (message) { - throw message; - } else { - throw new Error(`${string} does not match regex ${regexp}`); - } - } - if (!match && result) { - if (message) { - throw message; - } else { - throw new Error(`${string} does match regex ${regexp}`); - } - } -}; - - -if (!assert.match) { - const match = (string, regexp, message) => { - internalMatch(string, regexp, message, match); - }; - assert.match = match; -} -if (!assert.strict.match) assert.strict.match = assert.match; - -if (!assert.doesNotMatch) { - const doesNotMatch = (string, regexp, message) => { - internalMatch(string, regexp, message, doesNotMatch); - }; - assert.doesNotMatch = doesNotMatch; -} -if (!assert.strict.doesNotMatch) assert.strict.doesNotMatch = assert.doesNotMatch; - -module.exports = assert; diff --git a/src/tests/backend/common.js b/src/tests/backend/common.ts similarity index 85% rename from src/tests/backend/common.js rename to src/tests/backend/common.ts index b331cf05d..c0cfd1377 100644 --- a/src/tests/backend/common.js +++ b/src/tests/backend/common.ts @@ -1,5 +1,7 @@ 'use strict'; +import {MapArrayType} from "../../node/types/MapType"; + const AttributePool = require('../../static/js/AttributePool'); const apiHandler = require('../../node/handler/APIHandler'); const assert = require('assert').strict; @@ -10,11 +12,11 @@ const process = require('process'); const server = require('../../node/server'); const setCookieParser = require('set-cookie-parser'); const settings = require('../../node/utils/Settings'); -const supertest = require('supertest'); +import supertest from 'supertest'; const webaccess = require('../../node/hooks/express/webaccess'); -const backups = {}; -let agentPromise = null; +const backups:MapArrayType = {}; +let agentPromise:Promise|null = null; exports.apiKey = apiHandler.exportedForTestingOnly.apiKey; exports.agent = null; @@ -27,7 +29,7 @@ const logLevel = logger.level; // Mocha doesn't monitor unhandled Promise rejections, so convert them to uncaught exceptions. // https://github.com/mochajs/mocha/issues/2640 -process.on('unhandledRejection', (reason, promise) => { throw reason; }); +process.on('unhandledRejection', (reason: string) => { throw reason; }); before(async function () { this.timeout(60000); @@ -67,7 +69,7 @@ exports.init = async function () { await server.exit(); }); - agentResolve(exports.agent); + agentResolve!(exports.agent); return exports.agent; }; @@ -79,7 +81,7 @@ exports.init = async function () { * @param {string} event - The socket.io Socket event to listen for. * @returns The argument(s) passed to the event handler. */ -exports.waitForSocketEvent = async (socket, event) => { +exports.waitForSocketEvent = async (socket: any, event:string) => { const errorEvents = [ 'error', 'connect_error', @@ -90,7 +92,7 @@ exports.waitForSocketEvent = async (socket, event) => { const handlers = new Map(); let cancelTimeout; try { - const timeoutP = new Promise((resolve, reject) => { + const timeoutP = new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`timed out waiting for ${event} event`)); cancelTimeout = () => {}; @@ -102,14 +104,14 @@ exports.waitForSocketEvent = async (socket, event) => { }; }); const errorEventP = Promise.race(errorEvents.map((event) => new Promise((resolve, reject) => { - handlers.set(event, (errorString) => { + handlers.set(event, (errorString:string) => { logger.debug(`socket.io ${event} event: ${errorString}`); reject(new Error(errorString)); }); }))); - const eventP = new Promise((resolve) => { + const eventP = new Promise((resolve) => { // This will overwrite one of the above handlers if the user is waiting for an error event. - handlers.set(event, (...args) => { + handlers.set(event, (...args:string[]) => { logger.debug(`socket.io ${event} event`); if (args.length > 1) return resolve(args); resolve(args[0]); @@ -121,7 +123,7 @@ exports.waitForSocketEvent = async (socket, event) => { // the event arrives). return await Promise.race([timeoutP, errorEventP, eventP]); } finally { - cancelTimeout(); + cancelTimeout!(); for (const [event, handler] of handlers) socket.off(event, handler); } }; @@ -134,10 +136,11 @@ exports.waitForSocketEvent = async (socket, event) => { * nullish, no cookies are passed to the server. * @returns {io.Socket} A socket.io client Socket object. */ -exports.connect = async (res = null) => { +exports.connect = async (res:any = null) => { // 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( + // @ts-ignore ([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; '); logger.debug('socket.io connecting...'); @@ -147,7 +150,6 @@ exports.connect = async (res = null) => { } const socket = io(`${exports.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, padId}, @@ -168,11 +170,12 @@ exports.connect = async (res = null) => { * * @param {io.Socket} socket - Connected socket.io Socket object. * @param {string} padId - Which pad to join. + * @param token * @returns The CLIENT_VARS message from the server. */ -exports.handshake = async (socket, padId, token = padutils.generateAuthorToken()) => { +exports.handshake = async (socket: any, padId:string, token = padutils.generateAuthorToken()) => { logger.debug('sending CLIENT_READY...'); - socket.send({ + socket.emit('message', { component: 'pad', type: 'CLIENT_READY', padId, @@ -188,8 +191,11 @@ exports.handshake = async (socket, padId, token = padutils.generateAuthorToken() /** * Convenience wrapper around `socket.send()` that waits for acknowledgement. */ -exports.sendMessage = async (socket, message) => await new Promise((resolve, reject) => { - socket.send(message, (errInfo) => { +exports.sendMessage = async (socket: any, message:any) => await new Promise((resolve, reject) => { + socket.emit('message', message, (errInfo:{ + name: string, + message: string, + }) => { if (errInfo != null) { const {name, message} = errInfo; const err = new Error(message); @@ -204,7 +210,7 @@ exports.sendMessage = async (socket, message) => await new Promise((resolve, rej /** * Convenience function to send a USER_CHANGES message. Waits for acknowledgement. */ -exports.sendUserChanges = async (socket, data) => await exports.sendMessage(socket, { +exports.sendUserChanges = async (socket:any, data:any) => await exports.sendMessage(socket, { type: 'COLLABROOM', component: 'pad', data: { @@ -226,7 +232,7 @@ exports.sendUserChanges = async (socket, data) => await exports.sendMessage(sock * common.sendUserChanges(socket, {baseRev: rev, changeset}), * ]); */ -exports.waitForAcceptCommit = async (socket, wantRev) => { +exports.waitForAcceptCommit = async (socket:any, wantRev: number) => { const msg = await exports.waitForSocketEvent(socket, 'message'); assert.deepEqual(msg, { type: 'COLLABROOM', @@ -246,7 +252,7 @@ const alphabet = 'abcdefghijklmnopqrstuvwxyz'; * @param {string} [charset] - Characters to pick from. * @returns {string} */ -exports.randomString = (len = 10, charset = `${alphabet}${alphabet.toUpperCase()}0123456789`) => { +exports.randomString = (len: number = 10, charset: string = `${alphabet}${alphabet.toUpperCase()}0123456789`): string => { let ret = ''; while (ret.length < len) ret += charset[Math.floor(Math.random() * charset.length)]; return ret; diff --git a/src/tests/backend/fuzzImportTest.js b/src/tests/backend/fuzzImportTest.ts similarity index 91% rename from src/tests/backend/fuzzImportTest.js rename to src/tests/backend/fuzzImportTest.ts index ad55cdefb..8366e62d8 100644 --- a/src/tests/backend/fuzzImportTest.js +++ b/src/tests/backend/fuzzImportTest.ts @@ -2,16 +2,16 @@ * Fuzz testing the import endpoint * Usage: node fuzzImportTest.js */ +const settings = require('../container/loadSettings').loadSettings(); const common = require('./common'); const host = `http://${settings.ip}:${settings.port}`; const froth = require('mocha-froth'); -const settings = require('../container/loadSettings').loadSettings(); const axios = require('axios'); const apiKey = common.apiKey; const apiVersion = 1; const testPadId = `TEST_fuzz${makeid()}`; -const endPoint = function (point, version) { +const endPoint = function (point: string, version?:number) { version = version || apiVersion; return `/api/${version}/${point}?apikey=${apiKey}`; }; @@ -28,7 +28,7 @@ setTimeout(() => { } }, 5000); // wait 5 seconds -async function runTest(number) { +async function runTest(number: number) { await axios.get(`${host + endPoint('createPad')}&padID=${testPadId}`) .then(() => { const req = axios.post(`${host}/p/${testPadId}/import`) @@ -51,8 +51,9 @@ async function runTest(number) { }); }); }) - .catch(err => { - throw new Error('FAILURE', err); + .catch((err:any) => { + // @ts-ignore + throw new Error('FAILURE', err); }) } diff --git a/src/tests/backend/specs/ExportEtherpad.js b/src/tests/backend/specs/ExportEtherpad.ts similarity index 98% rename from src/tests/backend/specs/ExportEtherpad.js rename to src/tests/backend/specs/ExportEtherpad.ts index e66bb4633..677890cbb 100644 --- a/src/tests/backend/specs/ExportEtherpad.js +++ b/src/tests/backend/specs/ExportEtherpad.ts @@ -8,7 +8,7 @@ const plugins = require('../../../static/js/pluginfw/plugin_defs'); const readOnlyManager = require('../../../node/db/ReadOnlyManager'); describe(__filename, function () { - let padId; + let padId:string; beforeEach(async function () { padId = common.randomString(); @@ -16,7 +16,7 @@ describe(__filename, function () { }); describe('exportEtherpadAdditionalContent', function () { - let hookBackup; + let hookBackup: ()=>void; before(async function () { hookBackup = plugins.hooks.exportEtherpadAdditionalContent || []; diff --git a/src/tests/backend/specs/ImportEtherpad.js b/src/tests/backend/specs/ImportEtherpad.ts similarity index 94% rename from src/tests/backend/specs/ImportEtherpad.js rename to src/tests/backend/specs/ImportEtherpad.ts index 3158c308d..b0a208b4a 100644 --- a/src/tests/backend/specs/ImportEtherpad.js +++ b/src/tests/backend/specs/ImportEtherpad.ts @@ -1,5 +1,7 @@ 'use strict'; +import {MapArrayType} from "../../../node/types/MapType"; + const assert = require('assert').strict; const authorManager = require('../../../node/db/AuthorManager'); const db = require('../../../node/db/DB'); @@ -9,11 +11,11 @@ const plugins = require('../../../static/js/pluginfw/plugin_defs'); const {randomString} = require('../../../static/js/pad_utils'); describe(__filename, function () { - let padId; + let padId: string; const makeAuthorId = () => `a.${randomString(16)}`; - const makeExport = (authorId) => ({ + const makeExport = (authorId: string) => ({ 'pad:testing': { atext: { text: 'foo\n', @@ -65,7 +67,7 @@ describe(__filename, function () { it('changes are all or nothing', async function () { const authorId = makeAuthorId(); - const data = makeExport(authorId); + const data:MapArrayType = makeExport(authorId); data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0']; delete data['pad:testing:revs:0']; assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/); @@ -74,8 +76,8 @@ describe(__filename, function () { }); describe('author pad IDs', function () { - let existingAuthorId; - let newAuthorId; + let existingAuthorId: string; + let newAuthorId:string; beforeEach(async function () { existingAuthorId = (await authorManager.createAuthor('existing')).authorID; @@ -133,7 +135,7 @@ describe(__filename, function () { describe('enforces consistent pad ID', function () { it('pad record has different pad ID', async function () { - const data = makeExport(makeAuthorId()); + const data:MapArrayType = makeExport(makeAuthorId()); data['pad:differentPadId'] = data['pad:testing']; delete data['pad:testing']; assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/); @@ -147,7 +149,7 @@ describe(__filename, function () { }); it('pad rev record has different pad ID', async function () { - const data = makeExport(makeAuthorId()); + const data:MapArrayType = makeExport(makeAuthorId()); data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0']; delete data['pad:testing:revs:0']; assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/); @@ -170,7 +172,7 @@ describe(__filename, function () { }); describe('exportEtherpadAdditionalContent', function () { - let hookBackup; + let hookBackup: Function; before(async function () { hookBackup = plugins.hooks.exportEtherpadAdditionalContent || []; diff --git a/src/tests/backend/specs/Pad.js b/src/tests/backend/specs/Pad.ts similarity index 81% rename from src/tests/backend/specs/Pad.js rename to src/tests/backend/specs/Pad.ts index ec5c24631..b77bdf672 100644 --- a/src/tests/backend/specs/Pad.js +++ b/src/tests/backend/specs/Pad.ts @@ -1,7 +1,10 @@ 'use strict'; +import {PadType} from "../../../node/types/PadType"; + const Pad = require('../../../node/db/Pad'); -const assert = require('assert').strict; +import { strict as assert } from 'assert'; +import {MapArrayType} from "../../../node/types/MapType"; const authorManager = require('../../../node/db/AuthorManager'); const common = require('../common'); const padManager = require('../../../node/db/PadManager'); @@ -9,9 +12,9 @@ const plugins = require('../../../static/js/pluginfw/plugin_defs'); const settings = require('../../../node/utils/Settings'); describe(__filename, function () { - const backups = {}; - let pad; - let padId; + const backups:MapArrayType = {}; + let pad: PadType|null; + let padId: string; before(async function () { backups.hooks = { @@ -52,7 +55,7 @@ describe(__filename, function () { describe('padDefaultContent hook', function () { it('runs when a pad is created without specific text', async function () { - const p = new Promise((resolve) => { + const p = new Promise((resolve) => { plugins.hooks.padDefaultContent.push({hook_fn: () => resolve()}); }); pad = await padManager.getPad(padId); @@ -66,8 +69,8 @@ describe(__filename, function () { }); it('defaults to settings.defaultPadText', async function () { - const p = new Promise((resolve, reject) => { - plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, ctx) => { + const p = new Promise((resolve, reject) => { + plugins.hooks.padDefaultContent.push({hook_fn: async (hookName:string, ctx:any) => { try { assert.equal(ctx.type, 'text'); assert.equal(ctx.content, settings.defaultPadText); @@ -83,7 +86,9 @@ describe(__filename, function () { it('passes the pad object', async function () { const gotP = new Promise((resolve) => { - plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, {pad}) => resolve(pad)}); + plugins.hooks.padDefaultContent.push({hook_fn: async (hookName:string, {pad}:{ + pad: PadType, + }) => resolve(pad)}); }); pad = await padManager.getPad(padId); assert.equal(await gotP, pad); @@ -92,7 +97,9 @@ describe(__filename, function () { it('passes empty authorId if not provided', async function () { const gotP = new Promise((resolve) => { plugins.hooks.padDefaultContent.push( - {hook_fn: async (hookName, {authorId}) => resolve(authorId)}); + {hook_fn: async (hookName:string, {authorId}:{ + authorId: string, + }) => resolve(authorId)}); }); pad = await padManager.getPad(padId); assert.equal(await gotP, ''); @@ -102,7 +109,9 @@ describe(__filename, function () { const want = await authorManager.getAuthor4Token(`t.${padId}`); const gotP = new Promise((resolve) => { plugins.hooks.padDefaultContent.push( - {hook_fn: async (hookName, {authorId}) => resolve(authorId)}); + {hook_fn: async (hookName: string, {authorId}:{ + authorId: string, + }) => resolve(authorId)}); }); pad = await padManager.getPad(padId, null, want); assert.equal(await gotP, want); @@ -111,24 +120,24 @@ describe(__filename, function () { it('uses provided content', async function () { const want = 'hello world'; assert.notEqual(want, settings.defaultPadText); - plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, ctx) => { + plugins.hooks.padDefaultContent.push({hook_fn: async (hookName:string, ctx:any) => { ctx.type = 'text'; ctx.content = want; }}); pad = await padManager.getPad(padId); - assert.equal(pad.text(), `${want}\n`); + assert.equal(pad!.text(), `${want}\n`); }); it('cleans provided content', async function () { const input = 'foo\r\nbar\r\tbaz'; const want = 'foo\nbar\n baz'; assert.notEqual(want, settings.defaultPadText); - plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, ctx) => { + plugins.hooks.padDefaultContent.push({hook_fn: async (hookName:string, ctx:any) => { ctx.type = 'text'; ctx.content = input; }}); pad = await padManager.getPad(padId); - assert.equal(pad.text(), `${want}\n`); + assert.equal(pad!.text(), `${want}\n`); }); }); }); diff --git a/src/tests/backend/specs/SecretRotator.js b/src/tests/backend/specs/SecretRotator.ts similarity index 75% rename from src/tests/backend/specs/SecretRotator.js rename to src/tests/backend/specs/SecretRotator.ts index 1831ef71f..d95b6dba1 100644 --- a/src/tests/backend/specs/SecretRotator.js +++ b/src/tests/backend/specs/SecretRotator.ts @@ -1,25 +1,31 @@ 'use strict'; -const SecretRotator = require('../../../node/security/SecretRotator'); -const assert = require('assert').strict; +import {strict} from "assert"; const common = require('../common'); const crypto = require('../../../node/security/crypto'); const db = require('../../../node/db/DB'); +const SecretRotator = require("../../../node/security/SecretRotator").SecretRotator; const logger = common.logger; // Greatest common divisor. -const gcd = (...args) => ( +const gcd: Function = (...args:number[]) => ( args.length === 1 ? args[0] : args.length === 2 ? ((args[1]) ? gcd(args[1], args[0] % args[1]) : Math.abs(args[0])) : gcd(args[0], gcd(...args.slice(1)))); + // Least common multiple. -const lcm = (...args) => ( +const lcm:Function = (...args: number[]) => ( args.length === 1 ? args[0] : args.length === 2 ? Math.abs(args[0] * args[1]) / gcd(...args) : lcm(args[0], lcm(...args.slice(1)))); class FakeClock { + _now: number; + _nextId: number; + _idle: Promise; + timeouts: Map; + constructor() { logger.debug('new fake clock'); this._now = 0; @@ -29,10 +35,10 @@ class FakeClock { } _next() { return Math.min(...[...this.timeouts.values()].map((x) => x.when)); } - async setNow(t) { + async setNow(t: number) { logger.debug(`setting fake time to ${t}`); - assert(t >= this._now); - assert(t < Infinity); + strict(t >= this._now); + strict(t < Infinity); let n; while ((n = this._next()) <= t) { this._now = Math.max(this._now, Math.min(n, t)); @@ -42,7 +48,7 @@ class FakeClock { this._now = t; logger.debug(`fake time set to ${this._now}`); } - async advance(t) { await this.setNow(this._now + t); } + async advance(t: number) { await this.setNow(this._now + t); } async advanceToNext() { const n = this._next(); if (n < this._now) await this._fire(); @@ -68,34 +74,34 @@ class FakeClock { } get now() { return this._now; } - setTimeout(fn, wait = 0) { + setTimeout(fn:Function, wait = 0) { const when = this._now + wait; const id = this._nextId++; this.timeouts.set(id, {id, fn, when}); this._fire(); return id; } - clearTimeout(id) { this.timeouts.delete(id); } + clearTimeout(id:number) { this.timeouts.delete(id); } } // In JavaScript, the % operator is remainder, not modulus. -const mod = (a, n) => ((a % n) + n) % n; +const mod = (a: number, n:number) => ((a % n) + n) % n; describe(__filename, function () { - let dbPrefix; - let sr; + let dbPrefix: string; + let sr: any; let interval = 1e3; const lifetime = 1e4; - const intervalStart = (t) => t - mod(t, interval); - const hkdf = async (secret, salt, tN) => Buffer.from( + const intervalStart = (t: number) => t - mod(t, interval); + const hkdf = async (secret: string, salt:string, tN:number) => Buffer.from( await crypto.hkdf('sha256', secret, salt, `${tN}`, 32)).toString('hex'); - const newRotator = (s = null) => new SecretRotator(dbPrefix, interval, lifetime, s); + const newRotator = (s:string|null = null) => new SecretRotator(dbPrefix, interval, lifetime, s); - const setFakeClock = (sr, fc = null) => { + const setFakeClock = (sr: { _t: { now: () => number; setTimeout: (fn: Function, wait?: number) => number; clearTimeout: (id: number) => void; }; }, fc:FakeClock|null = null) => { if (fc == null) fc = new FakeClock(); sr._t = { - now: () => fc.now, + now: () => fc!.now, setTimeout: fc.setTimeout.bind(fc), clearTimeout: fc.clearTimeout.bind(fc), }; @@ -115,19 +121,19 @@ describe(__filename, function () { if (sr != null) sr.stop(); sr = null; await Promise.all( - (await db.findKeys(`${dbPrefix}:*`, null)).map(async (dbKey) => await db.remove(dbKey))); + (await db.findKeys(`${dbPrefix}:*`, null)).map(async (dbKey: string) => await db.remove(dbKey))); }); describe('constructor', function () { it('creates empty secrets array', async function () { sr = newRotator(); - assert.deepEqual(sr.secrets, []); + strict.deepEqual(sr.secrets, []); }); for (const invalidChar of '*:%') { it(`rejects database prefixes containing ${invalidChar}`, async function () { dbPrefix += invalidChar; - assert.throws(newRotator, /invalid char/); + strict.throws(newRotator, /invalid char/); }); } }); @@ -138,19 +144,19 @@ describe(__filename, function () { setFakeClock(sr); const {secrets} = sr; await sr.start(); - assert.equal(sr.secrets, secrets); + strict.equal(sr.secrets, secrets); }); it('derives secrets', async function () { sr = newRotator(); setFakeClock(sr); await sr.start(); - assert.equal(sr.secrets.length, 3); // Current (active), previous, and next. + strict.equal(sr.secrets.length, 3); // Current (active), previous, and next. for (const s of sr.secrets) { - assert.equal(typeof s, 'string'); - assert(s); + strict.equal(typeof s, 'string'); + strict(s); } - assert.equal(new Set(sr.secrets).size, sr.secrets.length); // The secrets should all differ. + strict.equal(new Set(sr.secrets).size, sr.secrets.length); // The secrets should all differ. }); it('publishes params', async function () { @@ -158,13 +164,13 @@ describe(__filename, function () { const fc = setFakeClock(sr); await sr.start(); const dbKeys = await db.findKeys(`${dbPrefix}:*`, null); - assert.equal(dbKeys.length, 1); + strict.equal(dbKeys.length, 1); const [id] = dbKeys; - assert(id.startsWith(`${dbPrefix}:`)); - assert.notEqual(id.slice(dbPrefix.length + 1), ''); + strict(id.startsWith(`${dbPrefix}:`)); + strict.notEqual(id.slice(dbPrefix.length + 1), ''); const p = await db.get(id); const {secret, salt} = p.algParams; - assert.deepEqual(p, { + strict.deepEqual(p, { algId: 1, algParams: { digest: 'sha256', @@ -177,11 +183,11 @@ describe(__filename, function () { interval, lifetime, }); - assert.equal(typeof salt, 'string'); - assert.match(salt, /^[0-9a-f]{64}$/); - assert.equal(typeof secret, 'string'); - assert.match(secret, /^[0-9a-f]{64}$/); - assert.deepEqual(sr.secrets, await Promise.all( + strict.equal(typeof salt, 'string'); + strict.match(salt, /^[0-9a-f]{64}$/); + strict.equal(typeof secret, 'string'); + strict.match(secret, /^[0-9a-f]{64}$/); + strict.deepEqual(sr.secrets, await Promise.all( [0, -interval, interval].map(async (tN) => await hkdf(secret, salt, tN)))); }); @@ -195,8 +201,8 @@ describe(__filename, function () { sr = newRotator(); setFakeClock(sr, fc); await sr.start(); - assert.deepEqual(sr.secrets, secrets); - assert.deepEqual(await db.findKeys(`${dbPrefix}:*`, null), dbKeys); + strict.deepEqual(sr.secrets, secrets); + strict.deepEqual(await db.findKeys(`${dbPrefix}:*`, null), dbKeys); }); it('deletes expired publications', async function () { @@ -204,7 +210,7 @@ describe(__filename, function () { const fc = setFakeClock(sr); await sr.start(); const [oldId] = await db.findKeys(`${dbPrefix}:*`, null); - assert(oldId != null); + strict(oldId != null); sr.stop(); const p = await db.get(oldId); await fc.setNow(p.end + p.lifetime + p.interval); @@ -212,9 +218,9 @@ describe(__filename, function () { setFakeClock(sr, fc); await sr.start(); const ids = await db.findKeys(`${dbPrefix}:*`, null); - assert.equal(ids.length, 1); + strict.equal(ids.length, 1); const [newId] = ids; - assert.notEqual(newId, oldId); + strict.notEqual(newId, oldId); }); it('keeps expired publications until interval past expiration', async function () { @@ -229,23 +235,23 @@ describe(__filename, function () { sr = newRotator(); setFakeClock(sr, fc); await sr.start(); - assert(sr.secrets.slice(1).includes(future)); + strict(sr.secrets.slice(1).includes(future)); // It should have created a new publication, not extended the life of the old publication. - assert.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2); - assert.deepEqual(await db.get(origId), p); + strict.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2); + strict.deepEqual(await db.get(origId), p); }); it('idempotent', async function () { sr = newRotator(); const fc = setFakeClock(sr); await sr.start(); - assert.equal(fc.timeouts.size, 1); + strict.equal(fc.timeouts.size, 1); const secrets = [...sr.secrets]; const dbKeys = await db.findKeys(`${dbPrefix}:*`, null); await sr.start(); - assert.equal(fc.timeouts.size, 1); - assert.deepEqual(sr.secrets, secrets); - assert.deepEqual(await db.findKeys(`${dbPrefix}:*`, null), dbKeys); + strict.equal(fc.timeouts.size, 1); + strict.deepEqual(sr.secrets, secrets); + strict.deepEqual(await db.findKeys(`${dbPrefix}:*`, null), dbKeys); }); describe(`schedules update at next interval (= ${interval})`, function () { @@ -262,16 +268,16 @@ describe(__filename, function () { const fc = setFakeClock(sr); await fc.setNow(now); await sr.start(); - assert.equal(fc.timeouts.size, 1); + strict.equal(fc.timeouts.size, 1); const [{when}] = fc.timeouts.values(); - assert.equal(when, want); + strict.equal(when, want); }); } it('multiple active params with different intervals', async function () { const intervals = [400, 600, 1000]; const lcmi = lcm(...intervals); - const wants = new Set(); + const wants:Set = new Set(); for (const i of intervals) for (let t = i; t <= lcmi; t += i) wants.add(t); const fcs = new FakeClock(); const srs = intervals.map((i) => { @@ -290,7 +296,7 @@ describe(__filename, function () { logger.debug(`next timeout should be at ${want}`); await fc.advanceToNext(); await fcs.setNow(fc.now); // Keep all of the publications alive. - assert.equal(fc.now, want); + strict.equal(fc.now, want); } } finally { for (const sr of srs) sr.stop(); @@ -304,9 +310,9 @@ describe(__filename, function () { sr = newRotator(); const fc = setFakeClock(sr); await sr.start(); - assert.notEqual(fc.timeouts.size, 0); + strict.notEqual(fc.timeouts.size, 0); sr.stop(); - assert.equal(fc.timeouts.size, 0); + strict.equal(fc.timeouts.size, 0); }); it('safe to call multiple times', async function () { @@ -325,14 +331,14 @@ describe(__filename, function () { // Use a time that isn't a multiple of interval in case there is a modular arithmetic bug that // would otherwise go undetected. await fc.setNow(1); - assert(mod(fc.now, interval) !== 0); + strict(mod(fc.now, interval) !== 0); await sr.start(); - assert.equal(sr.secrets.length, 4); // 1 for the legacy secret, 3 for past, current, future - assert(sr.secrets.slice(1).includes('legacy')); // Should not be the current secret. + strict.equal(sr.secrets.length, 4); // 1 for the legacy secret, 3 for past, current, future + strict(sr.secrets.slice(1).includes('legacy')); // Should not be the current secret. const ids = await db.findKeys(`${dbPrefix}:*`, null); - const params = (await Promise.all(ids.map(async (id) => await db.get(id)))) + const params = (await Promise.all(ids.map(async (id:string) => await db.get(id)))) .sort((a, b) => a.algId - b.algId); - assert.deepEqual(params, [ + strict.deepEqual(params, [ { algId: 0, algParams: 'legacy', @@ -358,26 +364,26 @@ describe(__filename, function () { sr = newRotator(); const fc = setFakeClock(sr); await fc.setNow(1); - assert(mod(fc.now, interval) !== 0); + strict(mod(fc.now, interval) !== 0); const wantTime = fc.now; await sr.start(); - assert.equal(sr.secrets.length, 3); + strict.equal(sr.secrets.length, 3); const [s1, s0, s2] = sr.secrets; // s1=current, s0=previous, s2=next sr.stop(); // Use a time that is not a multiple of interval off of epoch or wantTime just in case there // is a modular arithmetic bug that would otherwise go undetected. await fc.advance(interval + 1); - assert(mod(fc.now, interval) !== 0); - assert(mod(fc.now - wantTime, interval) !== 0); + strict(mod(fc.now, interval) !== 0); + strict(mod(fc.now - wantTime, interval) !== 0); sr = newRotator('legacy'); setFakeClock(sr, fc); await sr.start(); - assert.equal(sr.secrets.length, 5); // s0 through s3 and the legacy secret. - assert.deepEqual(sr.secrets, [s2, s1, s0, sr.secrets[3], 'legacy']); + strict.equal(sr.secrets.length, 5); // s0 through s3 and the legacy secret. + strict.deepEqual(sr.secrets, [s2, s1, s0, sr.secrets[3], 'legacy']); const ids = await db.findKeys(`${dbPrefix}:*`, null); - const params = (await Promise.all(ids.map(async (id) => await db.get(id)))) + const params = (await Promise.all(ids.map(async (id:string) => await db.get(id)))) .sort((a, b) => a.algId - b.algId); - assert.deepEqual(params, [ + strict.deepEqual(params, [ { algId: 0, algParams: 'legacy', @@ -405,8 +411,8 @@ describe(__filename, function () { sr = newRotator('legacy2'); setFakeClock(sr, fc); await sr.start(); - assert(sr.secrets.slice(1).includes('legacy1')); - assert(sr.secrets.slice(1).includes('legacy2')); + strict(sr.secrets.slice(1).includes('legacy1')); + strict(sr.secrets.slice(1).includes('legacy2')); }); it('multiple instances with the same legacy secret', async function () { @@ -417,9 +423,9 @@ describe(__filename, function () { sr = newRotator('legacy'); setFakeClock(sr, fc); await sr.start(); - assert.deepEqual(sr.secrets, [...new Set(sr.secrets)]); + strict.deepEqual(sr.secrets, [...new Set(sr.secrets)]); // There shouldn't be multiple publications for the same legacy secret. - assert.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2); + strict.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2); }); it('legacy secret is included for interval after expiration', async function () { @@ -431,7 +437,7 @@ describe(__filename, function () { sr = newRotator('legacy'); setFakeClock(sr, fc); await sr.start(); - assert(sr.secrets.slice(1).includes('legacy')); + strict(sr.secrets.slice(1).includes('legacy')); }); it('legacy secret is not included if the oldest secret is old enough', async function () { @@ -443,7 +449,7 @@ describe(__filename, function () { sr = newRotator('legacy'); setFakeClock(sr, fc); await sr.start(); - assert(!sr.secrets.includes('legacy')); + strict(!sr.secrets.includes('legacy')); }); it('dead secrets still affect legacy secret end time', async function () { @@ -456,8 +462,8 @@ describe(__filename, function () { sr = newRotator('legacy'); setFakeClock(sr, fc); await sr.start(); - assert(!sr.secrets.includes('legacy')); - assert(!sr.secrets.some((s) => secrets.has(s))); + strict(!sr.secrets.includes('legacy')); + strict(!sr.secrets.some((s:string) => secrets.has(s))); }); }); @@ -465,11 +471,11 @@ describe(__filename, function () { it('no rotation before start of interval', async function () { sr = newRotator(); const fc = setFakeClock(sr); - assert.equal(fc.now, 0); + strict.equal(fc.now, 0); await sr.start(); const secrets = [...sr.secrets]; await fc.advance(interval - 1); - assert.deepEqual(sr.secrets, secrets); + strict.deepEqual(sr.secrets, secrets); }); it('does not replace secrets array', async function () { @@ -479,8 +485,8 @@ describe(__filename, function () { const [current] = sr.secrets; const secrets = sr.secrets; await fc.advance(interval); - assert.notEqual(sr.secrets[0], current); - assert.equal(sr.secrets, secrets); + strict.notEqual(sr.secrets[0], current); + strict.equal(sr.secrets, secrets); }); it('future secret becomes current, new future is generated', async function () { @@ -488,11 +494,11 @@ describe(__filename, function () { const fc = setFakeClock(sr); await sr.start(); const secrets = new Set(sr.secrets); - assert.equal(secrets.size, 3); + strict.equal(secrets.size, 3); const [s1, s0, s2] = sr.secrets; await fc.advance(interval); - assert.deepEqual(sr.secrets, [s2, s1, s0, sr.secrets[3]]); - assert(!secrets.has(sr.secrets[3])); + strict.deepEqual(sr.secrets, [s2, s1, s0, sr.secrets[3]]); + strict(!secrets.has(sr.secrets[3])); }); it('expired publications are deleted', async function () { @@ -505,9 +511,9 @@ describe(__filename, function () { sr = newRotator(); setFakeClock(sr, fc); await sr.start(); - assert.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2); + strict.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2); await fc.advance(lifetime + (3 * origInterval)); - assert.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 1); + strict.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 1); }); it('old secrets are eventually removed', async function () { @@ -516,9 +522,9 @@ describe(__filename, function () { await sr.start(); const [, s0] = sr.secrets; await fc.advance(lifetime + interval - 1); - assert(sr.secrets.slice(1).includes(s0)); + strict(sr.secrets.slice(1).includes(s0)); await fc.advance(1); - assert(!sr.secrets.includes(s0)); + strict(!sr.secrets.includes(s0)); }); }); @@ -527,19 +533,19 @@ describe(__filename, function () { const srs = [newRotator(), newRotator()]; const fcs = srs.map((sr) => setFakeClock(sr)); for (const sr of srs) await sr.start(); // Don't use Promise.all() otherwise they race. - assert.deepEqual(srs[0].secrets, srs[1].secrets); + strict.deepEqual(srs[0].secrets, srs[1].secrets); // Advance fcs[0] to the end of the interval after fcs[1]. await fcs[0].advance((2 * interval) - 1); - assert(srs[0].secrets.includes(srs[1].secrets[0])); - assert(srs[1].secrets.includes(srs[0].secrets[0])); + strict(srs[0].secrets.includes(srs[1].secrets[0])); + strict(srs[1].secrets.includes(srs[0].secrets[0])); // Advance both by an interval. await Promise.all([fcs[1].advance(interval), fcs[0].advance(interval)]); - assert(srs[0].secrets.includes(srs[1].secrets[0])); - assert(srs[1].secrets.includes(srs[0].secrets[0])); + strict(srs[0].secrets.includes(srs[1].secrets[0])); + strict(srs[1].secrets.includes(srs[0].secrets[0])); // Advance fcs[1] to the end of the interval after fcs[0]. await Promise.all([fcs[1].advance((3 * interval) - 1), fcs[0].advance(1)]); - assert(srs[0].secrets.includes(srs[1].secrets[0])); - assert(srs[1].secrets.includes(srs[0].secrets[0])); + strict(srs[0].secrets.includes(srs[1].secrets[0])); + strict(srs[1].secrets.includes(srs[0].secrets[0])); }); it('start up out of sync', async function () { @@ -548,8 +554,8 @@ describe(__filename, function () { await fcs[0].advance((2 * interval) - 1); await srs[0].start(); // Must start before srs[1] so that srs[1] starts in srs[0]'s past. await srs[1].start(); - assert(srs[0].secrets.includes(srs[1].secrets[0])); - assert(srs[1].secrets.includes(srs[0].secrets[0])); + strict(srs[0].secrets.includes(srs[1].secrets[0])); + strict(srs[1].secrets.includes(srs[0].secrets[0])); }); }); }); diff --git a/src/tests/backend/specs/SessionStore.js b/src/tests/backend/specs/SessionStore.ts similarity index 76% rename from src/tests/backend/specs/SessionStore.js rename to src/tests/backend/specs/SessionStore.ts index dbf79c10d..5dfc44ff2 100644 --- a/src/tests/backend/specs/SessionStore.js +++ b/src/tests/backend/specs/SessionStore.ts @@ -1,19 +1,27 @@ 'use strict'; const SessionStore = require('../../../node/db/SessionStore'); -const assert = require('assert').strict; +import {strict as assert} from 'assert'; const common = require('../common'); const db = require('../../../node/db/DB'); -const util = require('util'); +import util from 'util'; + +type Session = { + set: (sid: string|null,sess:any, sess2:any) => void; + get: (sid:string|null) => any; + destroy: (sid:string|null) => void; + touch: (sid:string|null, sess:any, sess2:any) => void; + shutdown: () => void; +} describe(__filename, function () { - let ss; - let sid; + let ss: Session|null; + let sid: string|null; - const set = async (sess) => await util.promisify(ss.set).call(ss, sid, sess); - const get = async () => await util.promisify(ss.get).call(ss, sid); - const destroy = async () => await util.promisify(ss.destroy).call(ss, sid); - const touch = async (sess) => await util.promisify(ss.touch).call(ss, sid, sess); + const set = async (sess: string|null) => await util.promisify(ss!.set).call(ss, sid, sess); + const get = async () => await util.promisify(ss!.get).call(ss, sid); + const destroy = async () => await util.promisify(ss!.destroy).call(ss, sid); + const touch = async (sess: Session) => await util.promisify(ss!.touch).call(ss, sid, sess); before(async function () { await common.init(); @@ -40,13 +48,13 @@ describe(__filename, function () { }); it('set of non-expiring session', async function () { - const sess = {foo: 'bar', baz: {asdf: 'jkl;'}}; + const sess:any = {foo: 'bar', baz: {asdf: 'jkl;'}}; await set(sess); assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); }); it('set of session that expires', async function () { - const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; + const sess:any = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; await set(sess); assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); await new Promise((resolve) => setTimeout(resolve, 110)); @@ -55,25 +63,25 @@ describe(__filename, function () { }); it('set of already expired session', async function () { - const sess = {foo: 'bar', cookie: {expires: new Date(1)}}; + const sess:any = {foo: 'bar', cookie: {expires: new Date(1)}}; await set(sess); // No record should have been created. assert(await db.get(`sessionstorage:${sid}`) == null); }); it('switch from non-expiring to expiring', async function () { - const sess = {foo: 'bar'}; + const sess:any = {foo: 'bar'}; await set(sess); - const sess2 = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; + const sess2:any = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; await set(sess2); await new Promise((resolve) => setTimeout(resolve, 110)); assert(await db.get(`sessionstorage:${sid}`) == null); }); it('switch from expiring to non-expiring', async function () { - const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; + const sess:any = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; await set(sess); - const sess2 = {foo: 'bar'}; + const sess2:any = {foo: 'bar'}; await set(sess2); await new Promise((resolve) => setTimeout(resolve, 110)); assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2)); @@ -86,7 +94,7 @@ describe(__filename, function () { }); it('set+get round trip', async function () { - const sess = {foo: 'bar', baz: {asdf: 'jkl;'}}; + const sess:any = {foo: 'bar', baz: {asdf: 'jkl;'}}; await set(sess); assert.equal(JSON.stringify(await get()), JSON.stringify(sess)); }); @@ -114,7 +122,7 @@ describe(__filename, function () { }); it('external expiration update is picked up', async function () { - const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; + const sess:any = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; await set(sess); assert.equal(JSON.stringify(await get()), JSON.stringify(sess)); const sess2 = {...sess, cookie: {expires: new Date(Date.now() + 200)}}; @@ -128,10 +136,10 @@ describe(__filename, function () { describe('shutdown', function () { it('shutdown cancels timeouts', async function () { - const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; + const sess:any = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}}; await set(sess); assert.equal(JSON.stringify(await get()), JSON.stringify(sess)); - ss.shutdown(); + ss!.shutdown(); await new Promise((resolve) => setTimeout(resolve, 110)); // The record should not have been automatically purged. assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); @@ -140,14 +148,14 @@ describe(__filename, function () { describe('destroy', function () { it('destroy deletes the database record', async function () { - const sess = {cookie: {expires: new Date(Date.now() + 100)}}; + const sess:any = {cookie: {expires: new Date(Date.now() + 100)}}; await set(sess); await destroy(); assert(await db.get(`sessionstorage:${sid}`) == null); }); it('destroy cancels the timeout', async function () { - const sess = {cookie: {expires: new Date(Date.now() + 100)}}; + const sess:any = {cookie: {expires: new Date(Date.now() + 100)}}; await set(sess); await destroy(); await db.set(`sessionstorage:${sid}`, sess); @@ -162,16 +170,16 @@ describe(__filename, function () { describe('touch without refresh', function () { it('touch before set is equivalent to set if session expires', async function () { - const sess = {cookie: {expires: new Date(Date.now() + 1000)}}; + const sess:any = {cookie: {expires: new Date(Date.now() + 1000)}}; await touch(sess); assert.equal(JSON.stringify(await get()), JSON.stringify(sess)); }); it('touch updates observed expiration but not database', async function () { const start = Date.now(); - const sess = {cookie: {expires: new Date(start + 200)}}; + const sess:any = {cookie: {expires: new Date(start + 200)}}; await set(sess); - const sess2 = {cookie: {expires: new Date(start + 12000)}}; + const sess2:any = {cookie: {expires: new Date(start + 12000)}}; await touch(sess2); assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); assert.equal(JSON.stringify(await get()), JSON.stringify(sess2)); @@ -184,16 +192,16 @@ describe(__filename, function () { }); it('touch before set is equivalent to set if session expires', async function () { - const sess = {cookie: {expires: new Date(Date.now() + 1000)}}; + const sess:any = {cookie: {expires: new Date(Date.now() + 1000)}}; await touch(sess); assert.equal(JSON.stringify(await get()), JSON.stringify(sess)); }); it('touch before eligible for refresh updates expiration but not DB', async function () { const now = Date.now(); - const sess = {foo: 'bar', cookie: {expires: new Date(now + 1000)}}; + const sess:any = {foo: 'bar', cookie: {expires: new Date(now + 1000)}}; await set(sess); - const sess2 = {foo: 'bar', cookie: {expires: new Date(now + 1001)}}; + const sess2:any = {foo: 'bar', cookie: {expires: new Date(now + 1001)}}; await touch(sess2); assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); assert.equal(JSON.stringify(await get()), JSON.stringify(sess2)); @@ -201,10 +209,10 @@ describe(__filename, function () { it('touch before eligible for refresh updates timeout', async function () { const start = Date.now(); - const sess = {foo: 'bar', cookie: {expires: new Date(start + 200)}}; + const sess:any = {foo: 'bar', cookie: {expires: new Date(start + 200)}}; await set(sess); await new Promise((resolve) => setTimeout(resolve, 100)); - const sess2 = {foo: 'bar', cookie: {expires: new Date(start + 399)}}; + const sess2:any = {foo: 'bar', cookie: {expires: new Date(start + 399)}}; await touch(sess2); await new Promise((resolve) => setTimeout(resolve, 110)); assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess)); @@ -213,10 +221,10 @@ describe(__filename, function () { it('touch after eligible for refresh updates db', async function () { const start = Date.now(); - const sess = {foo: 'bar', cookie: {expires: new Date(start + 200)}}; + const sess:any = {foo: 'bar', cookie: {expires: new Date(start + 200)}}; await set(sess); await new Promise((resolve) => setTimeout(resolve, 100)); - const sess2 = {foo: 'bar', cookie: {expires: new Date(start + 400)}}; + const sess2:any = {foo: 'bar', cookie: {expires: new Date(start + 400)}}; await touch(sess2); await new Promise((resolve) => setTimeout(resolve, 110)); assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2)); @@ -225,7 +233,7 @@ describe(__filename, function () { it('refresh=0 updates db every time', async function () { ss = new SessionStore(0); - const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 1000)}}; + const sess:any = {foo: 'bar', cookie: {expires: new Date(Date.now() + 1000)}}; await set(sess); await db.remove(`sessionstorage:${sid}`); await touch(sess); // No change in expiration time. diff --git a/src/tests/backend/specs/Stream.js b/src/tests/backend/specs/Stream.ts similarity index 63% rename from src/tests/backend/specs/Stream.js rename to src/tests/backend/specs/Stream.ts index b98a5f3b4..c8a5a3e36 100644 --- a/src/tests/backend/specs/Stream.js +++ b/src/tests/backend/specs/Stream.ts @@ -1,9 +1,12 @@ 'use strict'; const Stream = require('../../../node/utils/Stream'); -const assert = require('assert').strict; +import {strict} from "assert"; class DemoIterable { + private value: number; + errs: Error[]; + rets: any[]; constructor() { this.value = 0; this.errs = []; @@ -17,14 +20,14 @@ class DemoIterable { return {value: this.value++, done: false}; } - throw(err) { + throw(err: any) { const alreadyCompleted = this.completed(); this.errs.push(err); if (alreadyCompleted) throw err; // Mimic standard generator objects. throw err; } - return(ret) { + return(ret: number) { const alreadyCompleted = this.completed(); this.rets.push(ret); if (alreadyCompleted) return {value: ret, done: true}; // Mimic standard generator objects. @@ -34,65 +37,69 @@ class DemoIterable { [Symbol.iterator]() { return this; } } -const assertUnhandledRejection = async (action, want) => { +const assertUnhandledRejection = async (action: any, want: any) => { // Temporarily remove unhandled Promise rejection listeners so that the unhandled rejections we // expect to see don't trigger a test failure (or terminate node). const event = 'unhandledRejection'; const listenersBackup = process.rawListeners(event); process.removeAllListeners(event); - let tempListener; - let asyncErr; + let tempListener: Function; + let asyncErr:any; try { - const seenErrPromise = new Promise((resolve) => { - tempListener = (err) => { - assert.equal(asyncErr, undefined); + const seenErrPromise = new Promise((resolve) => { + tempListener = (err:any) => { + strict.equal(asyncErr, undefined); asyncErr = err; resolve(); }; }); + // @ts-ignore process.on(event, tempListener); await action(); await seenErrPromise; } finally { // Restore the original listeners. + // @ts-ignore process.off(event, tempListener); - for (const listener of listenersBackup) process.on(event, listener); + for (const listener of listenersBackup) { // @ts-ignore + process.on(event, listener); + } } - await assert.rejects(Promise.reject(asyncErr), want); + await strict.rejects(Promise.reject(asyncErr), want); }; describe(__filename, function () { describe('basic behavior', function () { it('takes a generator', async function () { - assert.deepEqual([...new Stream((function* () { yield 0; yield 1; yield 2; })())], [0, 1, 2]); + strict.deepEqual([...new Stream((function* () { yield 0; yield 1; yield 2; })())], [0, 1, 2]); }); it('takes an array', async function () { - assert.deepEqual([...new Stream([0, 1, 2])], [0, 1, 2]); + strict.deepEqual([...new Stream([0, 1, 2])], [0, 1, 2]); }); it('takes an iterator', async function () { - assert.deepEqual([...new Stream([0, 1, 2][Symbol.iterator]())], [0, 1, 2]); + strict.deepEqual([...new Stream([0, 1, 2][Symbol.iterator]())], [0, 1, 2]); }); it('supports empty iterators', async function () { - assert.deepEqual([...new Stream([])], []); + strict.deepEqual([...new Stream([])], []); }); it('is resumable', async function () { const s = new Stream((function* () { yield 0; yield 1; yield 2; })()); let iter = s[Symbol.iterator](); - assert.deepEqual(iter.next(), {value: 0, done: false}); + strict.deepEqual(iter.next(), {value: 0, done: false}); iter = s[Symbol.iterator](); - assert.deepEqual(iter.next(), {value: 1, done: false}); - assert.deepEqual([...s], [2]); + strict.deepEqual(iter.next(), {value: 1, done: false}); + strict.deepEqual([...s], [2]); }); it('supports return value', async function () { const s = new Stream((function* () { yield 0; return 1; })()); const iter = s[Symbol.iterator](); - assert.deepEqual(iter.next(), {value: 0, done: false}); - assert.deepEqual(iter.next(), {value: 1, done: true}); + strict.deepEqual(iter.next(), {value: 0, done: false}); + strict.deepEqual(iter.next(), {value: 1, done: true}); }); it('does not start until needed', async function () { @@ -100,60 +107,60 @@ describe(__filename, function () { new Stream((function* () { yield lastYield = 0; })()); // Fetching from the underlying iterator should not start until the first value is fetched // from the stream. - assert.equal(lastYield, null); + strict.equal(lastYield, null); }); it('throw is propagated', async function () { const underlying = new DemoIterable(); const s = new Stream(underlying); const iter = s[Symbol.iterator](); - assert.deepEqual(iter.next(), {value: 0, done: false}); + strict.deepEqual(iter.next(), {value: 0, done: false}); const err = new Error('injected'); - assert.throws(() => iter.throw(err), err); - assert.equal(underlying.errs[0], err); + strict.throws(() => iter.throw(err), err); + strict.equal(underlying.errs[0], err); }); it('return is propagated', async function () { const underlying = new DemoIterable(); const s = new Stream(underlying); const iter = s[Symbol.iterator](); - assert.deepEqual(iter.next(), {value: 0, done: false}); - assert.deepEqual(iter.return(42), {value: 42, done: true}); - assert.equal(underlying.rets[0], 42); + strict.deepEqual(iter.next(), {value: 0, done: false}); + strict.deepEqual(iter.return(42), {value: 42, done: true}); + strict.equal(underlying.rets[0], 42); }); }); describe('range', function () { it('basic', async function () { - assert.deepEqual([...Stream.range(0, 3)], [0, 1, 2]); + strict.deepEqual([...Stream.range(0, 3)], [0, 1, 2]); }); it('empty', async function () { - assert.deepEqual([...Stream.range(0, 0)], []); + strict.deepEqual([...Stream.range(0, 0)], []); }); it('positive start', async function () { - assert.deepEqual([...Stream.range(3, 5)], [3, 4]); + strict.deepEqual([...Stream.range(3, 5)], [3, 4]); }); it('negative start', async function () { - assert.deepEqual([...Stream.range(-3, 0)], [-3, -2, -1]); + strict.deepEqual([...Stream.range(-3, 0)], [-3, -2, -1]); }); it('end before start', async function () { - assert.deepEqual([...Stream.range(3, 0)], []); + strict.deepEqual([...Stream.range(3, 0)], []); }); }); describe('batch', function () { it('empty', async function () { - assert.deepEqual([...new Stream([]).batch(10)], []); + strict.deepEqual([...new Stream([]).batch(10)], []); }); it('does not start until needed', async function () { let lastYield = null; new Stream((function* () { yield lastYield = 0; })()).batch(10); - assert.equal(lastYield, null); + strict.equal(lastYield, null); }); it('fewer than batch size', async function () { @@ -162,11 +169,11 @@ describe(__filename, function () { for (let i = 0; i < 5; i++) yield lastYield = i; })(); const s = new Stream(values).batch(10); - assert.equal(lastYield, null); - assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false}); - assert.equal(lastYield, 4); - assert.deepEqual([...s], [1, 2, 3, 4]); - assert.equal(lastYield, 4); + strict.equal(lastYield, null); + strict.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false}); + strict.equal(lastYield, 4); + strict.deepEqual([...s], [1, 2, 3, 4]); + strict.equal(lastYield, 4); }); it('exactly batch size', async function () { @@ -175,11 +182,11 @@ describe(__filename, function () { for (let i = 0; i < 5; i++) yield lastYield = i; })(); const s = new Stream(values).batch(5); - assert.equal(lastYield, null); - assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false}); - assert.equal(lastYield, 4); - assert.deepEqual([...s], [1, 2, 3, 4]); - assert.equal(lastYield, 4); + strict.equal(lastYield, null); + strict.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false}); + strict.equal(lastYield, 4); + strict.deepEqual([...s], [1, 2, 3, 4]); + strict.equal(lastYield, 4); }); it('multiple batches, last batch is not full', async function () { @@ -188,17 +195,17 @@ describe(__filename, function () { for (let i = 0; i < 10; i++) yield lastYield = i; })(); const s = new Stream(values).batch(3); - assert.equal(lastYield, null); + strict.equal(lastYield, null); const iter = s[Symbol.iterator](); - assert.deepEqual(iter.next(), {value: 0, done: false}); - assert.equal(lastYield, 2); - assert.deepEqual(iter.next(), {value: 1, done: false}); - assert.deepEqual(iter.next(), {value: 2, done: false}); - assert.equal(lastYield, 2); - assert.deepEqual(iter.next(), {value: 3, done: false}); - assert.equal(lastYield, 5); - assert.deepEqual([...s], [4, 5, 6, 7, 8, 9]); - assert.equal(lastYield, 9); + strict.deepEqual(iter.next(), {value: 0, done: false}); + strict.equal(lastYield, 2); + strict.deepEqual(iter.next(), {value: 1, done: false}); + strict.deepEqual(iter.next(), {value: 2, done: false}); + strict.equal(lastYield, 2); + strict.deepEqual(iter.next(), {value: 3, done: false}); + strict.equal(lastYield, 5); + strict.deepEqual([...s], [4, 5, 6, 7, 8, 9]); + strict.equal(lastYield, 9); }); it('batched Promise rejections are suppressed while iterating', async function () { @@ -215,9 +222,9 @@ describe(__filename, function () { const s = new Stream(values).batch(3); const iter = s[Symbol.iterator](); const nextp = iter.next().value; - assert.equal(lastYield, 'promise of 2'); - assert.equal(await nextp, 0); - await assert.rejects(iter.next().value, err); + strict.equal(lastYield, 'promise of 2'); + strict.equal(await nextp, 0); + await strict.rejects(iter.next().value, err); iter.return(); }); @@ -234,21 +241,21 @@ describe(__filename, function () { })(); const s = new Stream(values).batch(3); const iter = s[Symbol.iterator](); - assert.equal(await iter.next().value, 0); - assert.equal(lastYield, 'promise of 2'); + strict.equal(await iter.next().value, 0); + strict.equal(lastYield, 'promise of 2'); await assertUnhandledRejection(() => iter.return(), err); }); }); describe('buffer', function () { it('empty', async function () { - assert.deepEqual([...new Stream([]).buffer(10)], []); + strict.deepEqual([...new Stream([]).buffer(10)], []); }); it('does not start until needed', async function () { let lastYield = null; new Stream((function* () { yield lastYield = 0; })()).buffer(10); - assert.equal(lastYield, null); + strict.equal(lastYield, null); }); it('fewer than buffer size', async function () { @@ -257,11 +264,11 @@ describe(__filename, function () { for (let i = 0; i < 5; i++) yield lastYield = i; })(); const s = new Stream(values).buffer(10); - assert.equal(lastYield, null); - assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false}); - assert.equal(lastYield, 4); - assert.deepEqual([...s], [1, 2, 3, 4]); - assert.equal(lastYield, 4); + strict.equal(lastYield, null); + strict.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false}); + strict.equal(lastYield, 4); + strict.deepEqual([...s], [1, 2, 3, 4]); + strict.equal(lastYield, 4); }); it('exactly buffer size', async function () { @@ -270,11 +277,11 @@ describe(__filename, function () { for (let i = 0; i < 5; i++) yield lastYield = i; })(); const s = new Stream(values).buffer(5); - assert.equal(lastYield, null); - assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false}); - assert.equal(lastYield, 4); - assert.deepEqual([...s], [1, 2, 3, 4]); - assert.equal(lastYield, 4); + strict.equal(lastYield, null); + strict.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false}); + strict.equal(lastYield, 4); + strict.deepEqual([...s], [1, 2, 3, 4]); + strict.equal(lastYield, 4); }); it('more than buffer size', async function () { @@ -283,16 +290,16 @@ describe(__filename, function () { for (let i = 0; i < 10; i++) yield lastYield = i; })(); const s = new Stream(values).buffer(3); - assert.equal(lastYield, null); + strict.equal(lastYield, null); const iter = s[Symbol.iterator](); - assert.deepEqual(iter.next(), {value: 0, done: false}); - assert.equal(lastYield, 3); - assert.deepEqual(iter.next(), {value: 1, done: false}); - assert.equal(lastYield, 4); - assert.deepEqual(iter.next(), {value: 2, done: false}); - assert.equal(lastYield, 5); - assert.deepEqual([...s], [3, 4, 5, 6, 7, 8, 9]); - assert.equal(lastYield, 9); + strict.deepEqual(iter.next(), {value: 0, done: false}); + strict.equal(lastYield, 3); + strict.deepEqual(iter.next(), {value: 1, done: false}); + strict.equal(lastYield, 4); + strict.deepEqual(iter.next(), {value: 2, done: false}); + strict.equal(lastYield, 5); + strict.deepEqual([...s], [3, 4, 5, 6, 7, 8, 9]); + strict.equal(lastYield, 9); }); it('buffered Promise rejections are suppressed while iterating', async function () { @@ -309,9 +316,9 @@ describe(__filename, function () { const s = new Stream(values).buffer(3); const iter = s[Symbol.iterator](); const nextp = iter.next().value; - assert.equal(lastYield, 'promise of 2'); - assert.equal(await nextp, 0); - await assert.rejects(iter.next().value, err); + strict.equal(lastYield, 'promise of 2'); + strict.equal(await nextp, 0); + await strict.rejects(iter.next().value, err); iter.return(); }); @@ -328,8 +335,8 @@ describe(__filename, function () { })(); const s = new Stream(values).buffer(3); const iter = s[Symbol.iterator](); - assert.equal(await iter.next().value, 0); - assert.equal(lastYield, 'promise of 2'); + strict.equal(await iter.next().value, 0); + strict.equal(lastYield, 'promise of 2'); await assertUnhandledRejection(() => iter.return(), err); }); }); @@ -337,22 +344,22 @@ describe(__filename, function () { describe('map', function () { it('empty', async function () { let called = false; - assert.deepEqual([...new Stream([]).map((v) => called = true)], []); - assert.equal(called, false); + strict.deepEqual([...new Stream([]).map(() => called = true)], []); + strict.equal(called, false); }); it('does not start until needed', async function () { let called = false; - assert.deepEqual([...new Stream([]).map((v) => called = true)], []); - new Stream((function* () { yield 0; })()).map((v) => called = true); - assert.equal(called, false); + strict.deepEqual([...new Stream([]).map(() => called = true)], []); + new Stream((function* () { yield 0; })()).map(() => called = true); + strict.equal(called, false); }); it('works', async function () { - const calls = []; - assert.deepEqual( - [...new Stream([0, 1, 2]).map((v) => { calls.push(v); return 2 * v; })], [0, 2, 4]); - assert.deepEqual(calls, [0, 1, 2]); + const calls:any[] = []; + strict.deepEqual( + [...new Stream([0, 1, 2]).map((v:any) => { calls.push(v); return 2 * v; })], [0, 2, 4]); + strict.deepEqual(calls, [0, 1, 2]); }); }); }); diff --git a/src/tests/backend/specs/api/api.js b/src/tests/backend/specs/api/api.ts similarity index 90% rename from src/tests/backend/specs/api/api.js rename to src/tests/backend/specs/api/api.ts index 1415795b2..32681e5c9 100644 --- a/src/tests/backend/specs/api/api.js +++ b/src/tests/backend/specs/api/api.ts @@ -11,7 +11,7 @@ const common = require('../../common'); const validateOpenAPI = require('openapi-schema-validation').validate; -let agent; +let agent: any; const apiKey = common.apiKey; let apiVersion = 1; @@ -27,7 +27,7 @@ const makeid = () => { const testPadId = makeid(); -const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point:string) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; describe(__filename, function () { before(async function () { agent = await common.init(); }); @@ -35,7 +35,7 @@ describe(__filename, function () { it('can obtain API version', async function () { await agent.get('/api/') .expect(200) - .expect((res) => { + .expect((res:any) => { apiVersion = res.body.currentVersion; if (!res.body.currentVersion) throw new Error('No version set in API'); return; @@ -46,7 +46,7 @@ describe(__filename, function () { this.timeout(15000); await agent.get('/api/openapi.json') .expect(200) - .expect((res) => { + .expect((res:any) => { const {valid, errors} = validateOpenAPI(res.body, 3); if (!valid) { const prettyErrors = JSON.stringify(errors, null, 2); diff --git a/src/tests/backend/specs/api/characterEncoding.js b/src/tests/backend/specs/api/characterEncoding.ts similarity index 95% rename from src/tests/backend/specs/api/characterEncoding.js rename to src/tests/backend/specs/api/characterEncoding.ts index 2e579136b..7c2202a09 100644 --- a/src/tests/backend/specs/api/characterEncoding.js +++ b/src/tests/backend/specs/api/characterEncoding.ts @@ -11,12 +11,12 @@ const common = require('../../common'); const fs = require('fs'); const fsp = fs.promises; -let agent; +let agent:any; const apiKey = common.apiKey; let apiVersion = 1; const testPadId = makeid(); -const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point:string, version?:number) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; describe(__filename, function () { before(async function () { agent = await common.init(); }); diff --git a/src/tests/backend/specs/api/chat.js b/src/tests/backend/specs/api/chat.ts similarity index 91% rename from src/tests/backend/specs/api/chat.js rename to src/tests/backend/specs/api/chat.ts index b86917dab..dc61402bf 100644 --- a/src/tests/backend/specs/api/chat.js +++ b/src/tests/backend/specs/api/chat.ts @@ -1,16 +1,17 @@ 'use strict'; const common = require('../../common'); -const assert = require('assert').strict; -let agent; +import {strict as assert} from "assert"; + +let agent:any; const apiKey = common.apiKey; let apiVersion = 1; let authorID = ''; const padID = makeid(); const timestamp = Date.now(); -const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point:string) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; describe(__filename, function () { before(async function () { agent = await common.init(); }); @@ -18,7 +19,7 @@ describe(__filename, function () { describe('API Versioning', function () { it('errors if can not connect', async function () { await agent.get('/api/') - .expect((res) => { + .expect((res:any) => { apiVersion = res.body.currentVersion; if (!res.body.currentVersion) throw new Error('No version set in API'); return; @@ -42,7 +43,7 @@ describe(__filename, function () { describe('Chat functionality', function () { it('creates a new Pad', async function () { await agent.get(`${endPoint('createPad')}&padID=${padID}`) - .expect((res) => { + .expect((res:any) => { if (res.body.code !== 0) throw new Error('Unable to create new Pad'); }) .expect('Content-Type', /json/) @@ -51,7 +52,7 @@ describe(__filename, function () { it('Creates an author with a name set', async function () { await agent.get(endPoint('createAuthor')) - .expect((res) => { + .expect((res:any) => { if (res.body.code !== 0 || !res.body.data.authorID) { throw new Error('Unable to create author'); } @@ -63,7 +64,7 @@ describe(__filename, function () { it('Gets the head of chat before the first chat msg', async function () { await agent.get(`${endPoint('getChatHead')}&padID=${padID}`) - .expect((res) => { + .expect((res:any) => { if (res.body.data.chatHead !== -1) throw new Error('Chat Head Length is wrong'); if (res.body.code !== 0) throw new Error('Unable to get chat head'); }) @@ -74,7 +75,7 @@ describe(__filename, function () { it('Adds a chat message to the pad', async function () { await agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` + `&authorID=${authorID}&time=${timestamp}`) - .expect((res) => { + .expect((res:any) => { if (res.body.code !== 0) throw new Error('Unable to create chat message'); }) .expect('Content-Type', /json/) @@ -83,7 +84,7 @@ describe(__filename, function () { it('Gets the head of chat', async function () { await agent.get(`${endPoint('getChatHead')}&padID=${padID}`) - .expect((res) => { + .expect((res:any) => { if (res.body.data.chatHead !== 0) throw new Error('Chat Head Length is wrong'); if (res.body.code !== 0) throw new Error('Unable to get chat head'); @@ -96,7 +97,7 @@ describe(__filename, function () { await agent.get(`${endPoint('getChatHistory')}&padID=${padID}`) .expect('Content-Type', /json/) .expect(200) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0, 'Unable to get chat history'); assert.equal(res.body.data.messages.length, 1, 'Chat History Length is wrong'); assert.equal(res.body.data.messages[0].text, 'blalblalbha', 'Chat text does not match'); diff --git a/src/tests/backend/specs/api/fuzzImportTest.js b/src/tests/backend/specs/api/fuzzImportTest.ts similarity index 100% rename from src/tests/backend/specs/api/fuzzImportTest.js rename to src/tests/backend/specs/api/fuzzImportTest.ts diff --git a/src/tests/backend/specs/api/importexport.js b/src/tests/backend/specs/api/importexport.ts similarity index 98% rename from src/tests/backend/specs/api/importexport.js rename to src/tests/backend/specs/api/importexport.ts index ceced447f..a1ef64d87 100644 --- a/src/tests/backend/specs/api/importexport.js +++ b/src/tests/backend/specs/api/importexport.ts @@ -6,16 +6,17 @@ * TODO: unify those two files, and merge in a single one. */ -const assert = require('assert').strict; +import { strict as assert } from 'assert'; +import {MapArrayType} from "../../../../node/types/MapType"; const common = require('../../common'); -let agent; +let agent:any; const apiKey = common.apiKey; const apiVersion = 1; -const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point: string, version?:string) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; -const testImports = { +const testImports:MapArrayType = { 'malformed': { input: '
  • wtf', wantHTML: 'wtf

    ', diff --git a/src/tests/backend/specs/api/importexportGetPost.js b/src/tests/backend/specs/api/importexportGetPost.ts similarity index 89% rename from src/tests/backend/specs/api/importexportGetPost.js rename to src/tests/backend/specs/api/importexportGetPost.ts index e69f0d120..40bfb5552 100644 --- a/src/tests/backend/specs/api/importexportGetPost.js +++ b/src/tests/backend/specs/api/importexportGetPost.ts @@ -4,6 +4,8 @@ * Import and Export tests for the /p/whateverPadId/import and /p/whateverPadId/export endpoints. */ +import {MapArrayType} from "../../../../node/types/MapType"; + const assert = require('assert').strict; const common = require('../../common'); const fs = require('fs'); @@ -19,7 +21,7 @@ const wordXDoc = fs.readFileSync(`${__dirname}/test.docx`); const odtDoc = fs.readFileSync(`${__dirname}/test.odt`); const pdfDoc = fs.readFileSync(`${__dirname}/test.pdf`); -let agent; +let agent:any; const apiKey = common.apiKey; const apiVersion = 1; const testPadId = makeid(); @@ -48,7 +50,7 @@ describe(__filename, function () { it('finds the version tag', async function () { await agent.get('/api/') .expect(200) - .expect((res) => assert(res.body.currentVersion)); + .expect((res:any) => assert(res.body.currentVersion)); }); }); @@ -79,7 +81,7 @@ describe(__filename, function () { */ describe('Imports and Exports', function () { - const backups = {}; + const backups:MapArrayType = {}; beforeEach(async function () { backups.hooks = {}; @@ -104,17 +106,17 @@ describe(__filename, function () { await agent.get(`${endPoint('createPad')}&padID=${testPadId}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.equal(res.body.code, 0)); + .expect((res:any) => assert.equal(res.body.code, 0)); await agent.post(`/p/${testPadId}/import`) .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(200); await agent.get(`${endPoint('getText')}&padID=${testPadId}`) .expect(200) - .expect((res) => assert.equal(res.body.data.text, padText.toString())); + .expect((res:any) => assert.equal(res.body.data.text, padText.toString())); }); describe('export from read-only pad ID', function () { - let readOnlyId; + let readOnlyId:string; // This ought to be before(), but it must run after the top-level beforeEach() above. beforeEach(async function () { @@ -125,7 +127,7 @@ describe(__filename, function () { const res = await agent.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.equal(res.body.code, 0)); + .expect((res:any) => assert.equal(res.body.code, 0)); readOnlyId = res.body.data.readOnlyID; }); @@ -138,7 +140,7 @@ describe(__filename, function () { for (const exportType of ['html', 'txt', 'etherpad']) { describe(`export to ${exportType}`, function () { - let text; + let text:string; // This ought to be before(), but it must run after the top-level beforeEach() above. beforeEach(async function () { @@ -201,7 +203,7 @@ describe(__filename, function () { .attach('file', wordDoc, {filename: '/test.doc', contentType: 'application/msword'}) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.deepEqual(res.body, { + .expect((res:any) => assert.deepEqual(res.body, { code: 0, message: 'ok', data: {directDatabaseAccess: false}, @@ -212,7 +214,7 @@ describe(__filename, function () { await agent.get(`/p/${testPadId}/export/doc`) .buffer(true).parse(superagent.parse['application/octet-stream']) .expect(200) - .expect((res) => assert(res.body.length >= 9000)); + .expect((res:any) => assert(res.body.length >= 9000)); }); it('Tries to import .docx that uses soffice or abiword', async function () { @@ -224,7 +226,7 @@ describe(__filename, function () { }) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.deepEqual(res.body, { + .expect((res:any) => assert.deepEqual(res.body, { code: 0, message: 'ok', data: {directDatabaseAccess: false}, @@ -235,7 +237,7 @@ describe(__filename, function () { await agent.get(`/p/${testPadId}/export/doc`) .buffer(true).parse(superagent.parse['application/octet-stream']) .expect(200) - .expect((res) => assert(res.body.length >= 9100)); + .expect((res:any) => assert(res.body.length >= 9100)); }); it('Tries to import .pdf that uses soffice or abiword', async function () { @@ -243,7 +245,7 @@ describe(__filename, function () { .attach('file', pdfDoc, {filename: '/test.pdf', contentType: 'application/pdf'}) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.deepEqual(res.body, { + .expect((res:any) => assert.deepEqual(res.body, { code: 0, message: 'ok', data: {directDatabaseAccess: false}, @@ -254,7 +256,7 @@ describe(__filename, function () { await agent.get(`/p/${testPadId}/export/pdf`) .buffer(true).parse(superagent.parse['application/octet-stream']) .expect(200) - .expect((res) => assert(res.body.length >= 1000)); + .expect((res:any) => assert(res.body.length >= 1000)); }); it('Tries to import .odt that uses soffice or abiword', async function () { @@ -262,7 +264,7 @@ describe(__filename, function () { .attach('file', odtDoc, {filename: '/test.odt', contentType: 'application/odt'}) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.deepEqual(res.body, { + .expect((res:any) => assert.deepEqual(res.body, { code: 0, message: 'ok', data: {directDatabaseAccess: false}, @@ -273,7 +275,7 @@ describe(__filename, function () { await agent.get(`/p/${testPadId}/export/odt`) .buffer(true).parse(superagent.parse['application/octet-stream']) .expect(200) - .expect((res) => assert(res.body.length >= 7000)); + .expect((res:any) => assert(res.body.length >= 7000)); }); }); // End of AbiWord/LibreOffice tests. @@ -286,7 +288,7 @@ describe(__filename, function () { }) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.deepEqual(res.body, { + .expect((res:any) => assert.deepEqual(res.body, { code: 0, message: 'ok', data: {directDatabaseAccess: true}, @@ -316,7 +318,7 @@ describe(__filename, function () { .attach('file', padText, {filename: '/test.xasdasdxx', contentType: 'weirdness/jobby'}) .expect(400) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 1); assert.equal(res.body.message, 'uploadFailed'); }); @@ -367,7 +369,7 @@ describe(__filename, function () { }, }); - const importEtherpad = (records) => agent.post(`/p/${testPadId}/import`) + const importEtherpad = (records:any) => agent.post(`/p/${testPadId}/import`) .attach('file', Buffer.from(JSON.stringify(records), 'utf8'), { filename: '/test.etherpad', contentType: 'application/etherpad', @@ -381,7 +383,7 @@ describe(__filename, function () { await importEtherpad(records) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.deepEqual(res.body, { + .expect((res:any) => assert.deepEqual(res.body, { code: 0, message: 'ok', data: {directDatabaseAccess: true}, @@ -389,11 +391,11 @@ describe(__filename, function () { await agent.get(`/p/${testPadId}/export/txt`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.match(res.text, /foo/)); + .expect((res:any) => assert.match(res.text, /foo/)); }); it('missing rev', async function () { - const records = makeGoodExport(); + const records:MapArrayType = makeGoodExport(); delete records['pad:testing:revs:0']; await importEtherpad(records).expect(500); }); @@ -413,12 +415,13 @@ describe(__filename, function () { it('extra attrib in pool', async function () { const records = makeGoodExport(); const pool = records['pad:testing'].pool; + // @ts-ignore pool.numToAttrib[pool.nextNum] = ['key', 'value']; await importEtherpad(records).expect(500); }); it('changeset refers to non-existent attrib', async function () { - const records = makeGoodExport(); + const records:MapArrayType = makeGoodExport(); records['pad:testing:revs:1'] = { changeset: 'Z:4>4*1+4$asdf', meta: { @@ -441,7 +444,7 @@ describe(__filename, function () { }); it('missing chat message', async function () { - const records = makeGoodExport(); + const records:MapArrayType = makeGoodExport(); delete records['pad:testing:chat:0']; await importEtherpad(records).expect(500); }); @@ -520,7 +523,7 @@ describe(__filename, function () { }, }); - const importEtherpad = (records) => agent.post(`/p/${testPadId}/import`) + const importEtherpad = (records:MapArrayType) => agent.post(`/p/${testPadId}/import`) .attach('file', Buffer.from(JSON.stringify(records), 'utf8'), { filename: '/test.etherpad', contentType: 'application/etherpad', @@ -534,7 +537,7 @@ describe(__filename, function () { await importEtherpad(records) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.deepEqual(res.body, { + .expect((res:any) => assert.deepEqual(res.body, { code: 0, message: 'ok', data: {directDatabaseAccess: true}, @@ -542,84 +545,84 @@ describe(__filename, function () { await agent.get(`/p/${testPadId}/export/txt`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.equal(res.text, 'oofoo\n')); + .expect((res:any) => assert.equal(res.text, 'oofoo\n')); }); it('txt request rev 1', async function () { await agent.get(`/p/${testPadId}/1/export/txt`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.equal(res.text, 'ofoo\n')); + .expect((res:any) => assert.equal(res.text, 'ofoo\n')); }); it('txt request rev 2', async function () { await agent.get(`/p/${testPadId}/2/export/txt`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.equal(res.text, 'oofoo\n')); + .expect((res:any) => assert.equal(res.text, 'oofoo\n')); }); it('txt request rev 1test returns rev 1', async function () { await agent.get(`/p/${testPadId}/1test/export/txt`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.equal(res.text, 'ofoo\n')); + .expect((res:any) => assert.equal(res.text, 'ofoo\n')); }); it('txt request rev test1 is 403', async function () { await agent.get(`/p/${testPadId}/test1/export/txt`) .expect(500) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.match(res.text, /rev is not a number/)); + .expect((res:any) => assert.match(res.text, /rev is not a number/)); }); it('txt request rev 5 returns head rev', async function () { await agent.get(`/p/${testPadId}/5/export/txt`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.equal(res.text, 'oofoo\n')); + .expect((res:any) => assert.equal(res.text, 'oofoo\n')); }); it('html request rev 1', async function () { await agent.get(`/p/${testPadId}/1/export/html`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.match(res.text, /ofoo
    /)); + .expect((res:any) => assert.match(res.text, /ofoo
    /)); }); it('html request rev 2', async function () { await agent.get(`/p/${testPadId}/2/export/html`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.match(res.text, /oofoo
    /)); + .expect((res:any) => assert.match(res.text, /oofoo
    /)); }); it('html request rev 1test returns rev 1', async function () { await agent.get(`/p/${testPadId}/1test/export/html`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.match(res.text, /ofoo
    /)); + .expect((res:any) => assert.match(res.text, /ofoo
    /)); }); it('html request rev test1 results in 500 response', async function () { await agent.get(`/p/${testPadId}/test1/export/html`) .expect(500) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.match(res.text, /rev is not a number/)); + .expect((res:any) => assert.match(res.text, /rev is not a number/)); }); it('html request rev 5 returns head rev', async function () { await agent.get(`/p/${testPadId}/5/export/html`) .expect(200) .buffer(true).parse(superagent.parse.text) - .expect((res) => assert.match(res.text, /oofoo
    /)); + .expect((res:any) => assert.match(res.text, /oofoo
    /)); }); }); describe('Import authorization checks', function () { - let authorize; + let authorize: (arg0: any) => any; - const createTestPad = async (text) => { + const createTestPad = async (text:string) => { const pad = await padManager.getPad(testPadId); if (text) await pad.setText(text); return pad; @@ -631,7 +634,7 @@ describe(__filename, function () { await deleteTestPad(); settings.requireAuthorization = true; authorize = () => true; - plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => cb([authorize(req)])}]; + plugins.hooks.authorize = [{hook_fn: (hookName: string, {req}:any, cb:Function) => cb([authorize(req)])}]; }); afterEach(async function () { @@ -740,9 +743,8 @@ describe(__filename, function () { }); // End of tests. -const endPoint = (point, version) => { - version = version || apiVersion; - return `/api/${version}/${point}?apikey=${apiKey}`; +const endPoint = (point: string, version?:string) => { + return `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; }; function makeid() { diff --git a/src/tests/backend/specs/api/instance.js b/src/tests/backend/specs/api/instance.ts similarity index 91% rename from src/tests/backend/specs/api/instance.js rename to src/tests/backend/specs/api/instance.ts index 62202263e..fc348e5af 100644 --- a/src/tests/backend/specs/api/instance.js +++ b/src/tests/backend/specs/api/instance.ts @@ -7,11 +7,11 @@ */ const common = require('../../common'); -let agent; +let agent:any; const apiKey = common.apiKey; const apiVersion = '1.2.14'; -const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point: string, version?: number) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; describe(__filename, function () { before(async function () { agent = await common.init(); }); @@ -27,7 +27,7 @@ describe(__filename, function () { describe('getStats', function () { it('Gets the stats of a running instance', async function () { await agent.get(endPoint('getStats')) - .expect((res) => { + .expect((res:any) => { if (res.body.code !== 0) throw new Error('getStats() failed'); if (!('totalPads' in res.body.data && typeof res.body.data.totalPads === 'number')) { diff --git a/src/tests/backend/specs/api/pad.js b/src/tests/backend/specs/api/pad.ts similarity index 97% rename from src/tests/backend/specs/api/pad.js rename to src/tests/backend/specs/api/pad.ts index b8250741e..180494bb2 100644 --- a/src/tests/backend/specs/api/pad.js +++ b/src/tests/backend/specs/api/pad.ts @@ -11,7 +11,7 @@ const assert = require('assert').strict; const common = require('../../common'); const padManager = require('../../../../node/db/PadManager'); -let agent; +let agent:any; const apiKey = common.apiKey; let apiVersion = 1; const testPadId = makeid(); @@ -21,7 +21,7 @@ const anotherPadId = makeid(); let lastEdited = ''; const text = generateLongText(); -const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point: string, version?: string) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; /* * Html document with nested lists of different types, to test its import and @@ -508,13 +508,13 @@ describe(__filename, function () { await agent.get(`${endPoint('createPad')}&padID=${anotherPadId}&text=`) .expect('Content-Type', /json/) .expect(200) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0, 'Unable to create new Pad'); }); await agent.get(`${endPoint('getText')}&padID=${anotherPadId}`) .expect('Content-Type', /json/) .expect(200) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0, 'Unable to get pad text'); assert.equal(res.body.data.text, '\n', 'Pad text is not empty'); }); @@ -524,7 +524,7 @@ describe(__filename, function () { await agent.get(`${endPoint('deletePad')}&padID=${anotherPadId}`) .expect('Content-Type', /json/) .expect(200) - .expect((res) => { + .expect((res: any) => { assert.equal(res.body.code, 0, 'Unable to delete empty Pad'); }); }); @@ -532,7 +532,7 @@ describe(__filename, function () { describe('copyPadWithoutHistory', function () { const sourcePadId = makeid(); - let newPad; + let newPad:string; before(async function () { await createNewPadWithHtml(sourcePadId, ulHtml); @@ -612,7 +612,7 @@ describe(__filename, function () { // If appears in the source pad, or appears in the destination pad, then shared // state between the two attribute pools caused corruption. - const getHtml = async (padId) => { + const getHtml = async (padId:string) => { const res = await agent.get(`${endPoint('getHTML')}&padID=${padId}`) .expect(200) .expect('Content-Type', /json/); @@ -620,12 +620,12 @@ describe(__filename, function () { return res.body.data.html; }; - const setBody = async (padId, bodyHtml) => { + const setBody = async (padId: string, bodyHtml: string) => { await agent.post(endPoint('setHTML')) .send({padID: padId, html: `${bodyHtml}`}) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.equal(res.body.code, 0)); + .expect((res: any) => assert.equal(res.body.code, 0)); }; const origHtml = await getHtml(sourcePadId); @@ -635,7 +635,7 @@ describe(__filename, function () { `&destinationID=${newPad}&force=false`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => assert.equal(res.body.code, 0)); + .expect((res:any) => assert.equal(res.body.code, 0)); const newBodySrc = 'bold'; const newBodyDst = 'italic'; @@ -650,7 +650,7 @@ describe(__filename, function () { // Force the server to re-read the pads from the database. This rebuilds the attribute pool // objects from scratch, ensuring that an internally inconsistent attribute pool object did // not cause the above tests to accidentally pass. - const reInitPad = async (padId) => { + const reInitPad = async (padId:string) => { const pad = await padManager.getPad(padId); await pad.init(); }; @@ -671,7 +671,7 @@ describe(__filename, function () { */ -const createNewPadWithHtml = async (padId, html) => { +const createNewPadWithHtml = async (padId: string, html: string) => { await agent.get(`${endPoint('createPad')}&padID=${padId}`); await agent.post(endPoint('setHTML')) .send({ diff --git a/src/tests/backend/specs/api/restoreRevision.js b/src/tests/backend/specs/api/restoreRevision.ts similarity index 91% rename from src/tests/backend/specs/api/restoreRevision.js rename to src/tests/backend/specs/api/restoreRevision.ts index 98709ab9b..28a509012 100644 --- a/src/tests/backend/specs/api/restoreRevision.js +++ b/src/tests/backend/specs/api/restoreRevision.ts @@ -1,17 +1,19 @@ 'use strict'; +import {PadType} from "../../../../node/types/PadType"; + const assert = require('assert').strict; const authorManager = require('../../../../node/db/AuthorManager'); const common = require('../../common'); const padManager = require('../../../../node/db/PadManager'); describe(__filename, function () { - let agent; - let authorId; - let padId; - let pad; + let agent:any; + let authorId: string; + let padId: string; + let pad: PadType; - const restoreRevision = async (v, padId, rev, authorId = null) => { + const restoreRevision = async (v:string, padId: string, rev: number, authorId:string|null = null) => { const p = new URLSearchParams(Object.entries({ apikey: common.apiKey, padID: padId, diff --git a/src/tests/backend/specs/api/sessionsAndGroups.js b/src/tests/backend/specs/api/sessionsAndGroups.ts similarity index 90% rename from src/tests/backend/specs/api/sessionsAndGroups.js rename to src/tests/backend/specs/api/sessionsAndGroups.ts index eb181f01f..1c3196214 100644 --- a/src/tests/backend/specs/api/sessionsAndGroups.js +++ b/src/tests/backend/specs/api/sessionsAndGroups.ts @@ -4,7 +4,7 @@ const assert = require('assert').strict; const common = require('../../common'); const db = require('../../../../node/db/DB'); -let agent; +let agent:any; const apiKey = common.apiKey; let apiVersion = 1; let groupID = ''; @@ -12,7 +12,7 @@ let authorID = ''; let sessionID = ''; let padID = makeid(); -const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point:string) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; describe(__filename, function () { before(async function () { agent = await common.init(); }); @@ -21,7 +21,7 @@ describe(__filename, function () { it('errors if can not connect', async function () { await agent.get('/api/') .expect(200) - .expect((res) => { + .expect((res:any) => { assert(res.body.currentVersion); apiVersion = res.body.currentVersion; }); @@ -63,7 +63,7 @@ describe(__filename, function () { await agent.get(endPoint('createGroup')) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.groupID); groupID = res.body.data.groupID; @@ -74,7 +74,7 @@ describe(__filename, function () { await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert.equal(res.body.data, null); }); @@ -84,18 +84,18 @@ describe(__filename, function () { await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); }); }); it('createGroupIfNotExistsFor', async function () { const mapper = makeid(); - let groupId; + let groupId: string; await agent.get(`${endPoint('createGroupIfNotExistsFor')}&groupMapper=${mapper}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); groupId = res.body.data.groupID; assert(groupId); @@ -104,16 +104,16 @@ describe(__filename, function () { await agent.get(`${endPoint('createGroupIfNotExistsFor')}&groupMapper=${mapper}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert.equal(res.body.data.groupID, groupId); }); // Deleting the group should clean up the mapping. - assert.equal(await db.get(`mapper2group:${mapper}`), groupId); - await agent.get(`${endPoint('deleteGroup')}&groupID=${groupId}`) + assert.equal(await db.get(`mapper2group:${mapper}`), groupId!); + await agent.get(`${endPoint('deleteGroup')}&groupID=${groupId!}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); }); assert(await db.get(`mapper2group:${mapper}`) == null); @@ -125,7 +125,7 @@ describe(__filename, function () { await agent.get(endPoint('createGroup')) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.groupID); groupID = res.body.data.groupID; @@ -136,7 +136,7 @@ describe(__filename, function () { await agent.get(endPoint('createAuthor')) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.authorID); authorID = res.body.data.authorID; @@ -148,7 +148,7 @@ describe(__filename, function () { '&validUntil=999999999999') .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.sessionID); sessionID = res.body.data.sessionID; @@ -160,7 +160,7 @@ describe(__filename, function () { '&validUntil=999999999999') .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.sessionID); sessionID = res.body.data.sessionID; @@ -171,7 +171,7 @@ describe(__filename, function () { await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x1234567`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); }); }); @@ -180,7 +180,7 @@ describe(__filename, function () { await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x12345678`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); }); }); @@ -189,7 +189,7 @@ describe(__filename, function () { await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); }); }); @@ -201,7 +201,7 @@ describe(__filename, function () { await agent.get(endPoint('createGroup')) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.groupID); groupID = res.body.data.groupID; @@ -212,7 +212,7 @@ describe(__filename, function () { await agent.get(endPoint('createAuthor')) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.authorID); }); @@ -222,7 +222,7 @@ describe(__filename, function () { await agent.get(`${endPoint('createAuthor')}&name=john`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.authorID); authorID = res.body.data.authorID; // we will be this author for the rest of the tests @@ -233,7 +233,7 @@ describe(__filename, function () { await agent.get(`${endPoint('createAuthorIfNotExistsFor')}&authorMapper=chris`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.authorID); }); @@ -243,7 +243,7 @@ describe(__filename, function () { await agent.get(`${endPoint('getAuthorName')}&authorID=${authorID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert.equal(res.body.data, 'john'); }); @@ -256,7 +256,7 @@ describe(__filename, function () { '&validUntil=999999999999') .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.sessionID); sessionID = res.body.data.sessionID; @@ -267,7 +267,7 @@ describe(__filename, function () { await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert(res.body.data.groupID); assert(res.body.data.authorID); @@ -279,7 +279,7 @@ describe(__filename, function () { await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert.equal(typeof res.body.data, 'object'); }); @@ -289,7 +289,7 @@ describe(__filename, function () { await agent.get(`${endPoint('deleteSession')}&sessionID=${sessionID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); }); }); @@ -298,7 +298,7 @@ describe(__filename, function () { await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 1); }); }); @@ -309,7 +309,7 @@ describe(__filename, function () { await agent.get(`${endPoint('listPads')}&groupID=${groupID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert.equal(res.body.data.padIDs.length, 0); }); @@ -319,7 +319,7 @@ describe(__filename, function () { await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=${padID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); padID = res.body.data.padID; }); @@ -329,7 +329,7 @@ describe(__filename, function () { await agent.get(`${endPoint('listPads')}&groupID=${groupID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert.equal(res.body.data.padIDs.length, 1); }); @@ -341,7 +341,7 @@ describe(__filename, function () { await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert.equal(res.body.data.publicStatus, false); }); @@ -351,7 +351,7 @@ describe(__filename, function () { await agent.get(`${endPoint('setPublicStatus')}&padID=${padID}&publicStatus=true`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); }); }); @@ -360,7 +360,7 @@ describe(__filename, function () { await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert.equal(res.body.data.publicStatus, true); }); @@ -376,7 +376,7 @@ describe(__filename, function () { await agent.get(`${endPoint('listPadsOfAuthor')}&authorID=${authorID}`) .expect(200) .expect('Content-Type', /json/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.body.code, 0); assert.equal(res.body.data.padIDs.length, 0); }); diff --git a/src/tests/backend/specs/caching_middleware.js b/src/tests/backend/specs/caching_middleware.ts similarity index 81% rename from src/tests/backend/specs/caching_middleware.js rename to src/tests/backend/specs/caching_middleware.ts index ebfd65d9f..3051ee1e7 100644 --- a/src/tests/backend/specs/caching_middleware.js +++ b/src/tests/backend/specs/caching_middleware.ts @@ -1,5 +1,7 @@ 'use strict'; +import {MapArrayType} from "../../../node/types/MapType"; + /** * caching_middleware is responsible for serving everything under path `/javascripts/` * That includes packages as defined in `src/node/utils/tar.json` and probably also plugin code @@ -7,11 +9,12 @@ */ const common = require('../common'); -const assert = require('../assert-legacy').strict; -const queryString = require('querystring'); +import {strict as assert} from 'assert'; +import queryString from 'querystring'; const settings = require('../../../node/utils/Settings'); +import {it, describe} from 'mocha' -let agent; +let agent: any; /** * Hack! Returns true if the resource is not plaintext @@ -19,30 +22,35 @@ let agent; * URL. * * @param {string} fileContent the response body - * @param {URI} resource resource URI + * @param {URL} resource resource URI * @returns {boolean} if it is plaintext */ -const isPlaintextResponse = (fileContent, resource) => { +const isPlaintextResponse = (fileContent: string, resource:string): boolean => { // callback=require.define&v=1234 const query = (new URL(resource, 'http://localhost')).search.slice(1); // require.define const jsonp = queryString.parse(query).callback; // returns true if the first letters in fileContent equal the content of `jsonp` - return fileContent.substring(0, jsonp.length) === jsonp; + return fileContent.substring(0, jsonp!.length) === jsonp; }; + +type RequestType = { + _shouldUnzip: () => boolean; +} + /** * A hack to disable `superagent`'s auto unzip functionality * * @param {Request} request */ -const disableAutoDeflate = (request) => { +const disableAutoDeflate = (request: RequestType) => { request._shouldUnzip = () => false; }; describe(__filename, function () { - const backups = {}; + const backups:MapArrayType = {}; const fantasyEncoding = 'brainwaves'; // non-working encoding until https://github.com/visionmedia/superagent/pull/1560 is resolved const packages = [ '/javascripts/lib/ep_etherpad-lite/static/js/ace2_common.js?callback=require.define', @@ -74,7 +82,7 @@ describe(__filename, function () { .use(disableAutoDeflate) .expect(200) .expect('Content-Type', /application\/javascript/) - .expect((res) => { + .expect((res:any) => { assert.equal(res.header['content-encoding'], undefined); assert(isPlaintextResponse(res.text, resource)); }); @@ -91,7 +99,7 @@ describe(__filename, function () { .expect(200) .expect('Content-Type', /application\/javascript/) .expect('Content-Encoding', 'gzip') - .expect((res) => { + .expect((res:any) => { assert(!isPlaintextResponse(res.text, resource)); }); }); @@ -102,7 +110,7 @@ describe(__filename, function () { await agent.get(packages[0]) .set('Accept-Encoding', fantasyEncoding) .expect(200) - .expect((res) => assert.equal(res.header['content-encoding'], undefined)); + .expect((res:any) => assert.equal(res.header['content-encoding'], undefined)); await agent.get(packages[0]) .set('Accept-Encoding', 'gzip') .expect(200) @@ -110,7 +118,7 @@ describe(__filename, function () { await agent.get(packages[0]) .set('Accept-Encoding', fantasyEncoding) .expect(200) - .expect((res) => assert.equal(res.header['content-encoding'], undefined)); + .expect((res:any) => assert.equal(res.header['content-encoding'], undefined)); }); }); } diff --git a/src/tests/backend/specs/chat.js b/src/tests/backend/specs/chat.ts similarity index 75% rename from src/tests/backend/specs/chat.js rename to src/tests/backend/specs/chat.ts index aefa64183..5070a30a1 100644 --- a/src/tests/backend/specs/chat.js +++ b/src/tests/backend/specs/chat.ts @@ -1,5 +1,8 @@ 'use strict'; +import {MapArrayType} from "../../../node/types/MapType"; +import {PluginDef} from "../../../node/types/PartType"; + const ChatMessage = require('../../../static/js/ChatMessage'); const {Pad} = require('../../../node/db/Pad'); const assert = require('assert').strict; @@ -9,16 +12,23 @@ const pluginDefs = require('../../../static/js/pluginfw/plugin_defs'); const logger = common.logger; -const checkHook = async (hookName, checkFn) => { +type CheckFN = ({message, pad, padId}:{ + message?: typeof ChatMessage, + pad?: typeof Pad, + padId?: string, +})=>void; + +const checkHook = async (hookName: string, checkFn?:CheckFN) => { if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = []; - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { pluginDefs.hooks[hookName].push({ - hook_fn: async (hookName, context) => { + hook_fn: async (hookName: string, context:any) => { if (checkFn == null) return; logger.debug(`hook ${hookName} invoked`); try { // Make sure checkFn is called only once. const _checkFn = checkFn; + // @ts-ignore checkFn = null; await _checkFn(context); } catch (err) { @@ -31,24 +41,27 @@ const checkHook = async (hookName, checkFn) => { }); }; -const sendMessage = (socket, data) => { - socket.send({ +const sendMessage = (socket: any, data:any) => { + socket.emit('message', { type: 'COLLABROOM', component: 'pad', data, }); }; -const sendChat = (socket, message) => sendMessage(socket, {type: 'CHAT_MESSAGE', message}); +const sendChat = (socket:any, message:{ + text: string, + +}) => sendMessage(socket, {type: 'CHAT_MESSAGE', message}); describe(__filename, function () { const padId = 'testChatPad'; - const hooksBackup = {}; + const hooksBackup:MapArrayType = {}; before(async function () { for (const [name, defs] of Object.entries(pluginDefs.hooks)) { if (defs == null) continue; - hooksBackup[name] = defs; + hooksBackup[name] = defs as PluginDef[]; } }); @@ -71,8 +84,8 @@ describe(__filename, function () { }); describe('chatNewMessage hook', function () { - let authorId; - let socket; + let authorId: string; + let socket: any; beforeEach(async function () { socket = await common.connect(); @@ -91,11 +104,11 @@ describe(__filename, function () { assert(message != null); assert(message instanceof ChatMessage); assert.equal(message.authorId, authorId); - assert.equal(message.text, this.test.title); + assert.equal(message.text, this.test!.title); assert(message.time >= start); assert(message.time <= Date.now()); }), - sendChat(socket, {text: this.test.title}), + sendChat(socket, {text: this.test!.title}), ]); }); @@ -106,7 +119,7 @@ describe(__filename, function () { assert(pad instanceof Pad); assert.equal(pad.id, padId); }), - sendChat(socket, {text: this.test.title}), + sendChat(socket, {text: this.test!.title}), ]); }); @@ -115,13 +128,19 @@ describe(__filename, function () { checkHook('chatNewMessage', (context) => { assert.equal(context.padId, padId); }), - sendChat(socket, {text: this.test.title}), + sendChat(socket, {text: this.test!.title}), ]); }); it('mutations propagate', async function () { - const listen = async (type) => await new Promise((resolve) => { - const handler = (msg) => { + + type Message = { + type: string, + data: any, + } + + const listen = async (type: string) => await new Promise((resolve) => { + const handler = (msg:Message) => { if (msg.type !== 'COLLABROOM') return; if (msg.data == null || msg.data.type !== type) return; resolve(msg.data); @@ -130,8 +149,8 @@ describe(__filename, function () { socket.on('message', handler); }); - const modifiedText = `${this.test.title} `; - const customMetadata = {foo: this.test.title}; + const modifiedText = `${this.test!.title} `; + const customMetadata = {foo: this.test!.title}; await Promise.all([ checkHook('chatNewMessage', ({message}) => { message.text = modifiedText; @@ -143,7 +162,7 @@ describe(__filename, function () { assert.equal(message.text, modifiedText); assert.deepEqual(message.customMetadata, customMetadata); })(), - sendChat(socket, {text: this.test.title}), + sendChat(socket, {text: this.test!.title}), ]); // Simulate fetch of historical chat messages when a pad is first loaded. await Promise.all([ diff --git a/src/tests/backend/specs/contentcollector.js b/src/tests/backend/specs/contentcollector.ts similarity index 97% rename from src/tests/backend/specs/contentcollector.js rename to src/tests/backend/specs/contentcollector.ts index a4696307e..51ae0002f 100644 --- a/src/tests/backend/specs/contentcollector.js +++ b/src/tests/backend/specs/contentcollector.ts @@ -9,6 +9,8 @@ * If you add tests here, please also add them to importexport.js */ +import {APool} from "../../../node/types/PadType"; + const AttributePool = require('../../../static/js/AttributePool'); const Changeset = require('../../../static/js/Changeset'); const assert = require('assert').strict; @@ -334,8 +336,11 @@ pre describe(__filename, function () { for (const tc of testCases) { describe(tc.description, function () { - let apool; - let result; + let apool: APool; + let result: { + lines: string[], + lineAttribs: string[], + }; before(async function () { if (tc.disabled) return this.skip(); @@ -366,15 +371,15 @@ describe(__filename, function () { }); it('attributes are sorted in canonical order', async function () { - const gotAttribs = []; + const gotAttribs:string[][][] = []; const wantAttribs = []; for (const aline of result.lineAttribs) { - const gotAlineAttribs = []; + const gotAlineAttribs:string[][] = []; gotAttribs.push(gotAlineAttribs); - const wantAlineAttribs = []; + const wantAlineAttribs:string[] = []; wantAttribs.push(wantAlineAttribs); for (const op of Changeset.deserializeOps(aline)) { - const gotOpAttribs = [...attributes.attribsFromString(op.attribs, apool)]; + const gotOpAttribs:string[] = [...attributes.attribsFromString(op.attribs, apool)]; gotAlineAttribs.push(gotOpAttribs); wantAlineAttribs.push(attributes.sort([...gotOpAttribs])); } diff --git a/src/tests/backend/specs/crypto.js b/src/tests/backend/specs/crypto.js deleted file mode 100644 index cde096f01..000000000 --- a/src/tests/backend/specs/crypto.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -const assert = require('assert').strict; -const {Buffer} = require('buffer'); -const crypto = require('../../../node/security/crypto'); -const nodeCrypto = require('crypto'); -const util = require('util'); - -const nodeHkdf = nodeCrypto.hkdf ? util.promisify(nodeCrypto.hkdf) : null; - -const ab2hex = (ab) => Buffer.from(ab).toString('hex'); diff --git a/src/tests/backend/specs/crypto.ts b/src/tests/backend/specs/crypto.ts new file mode 100644 index 000000000..62d79f1b3 --- /dev/null +++ b/src/tests/backend/specs/crypto.ts @@ -0,0 +1,10 @@ +'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.js b/src/tests/backend/specs/export.ts similarity index 84% rename from src/tests/backend/specs/export.js rename to src/tests/backend/specs/export.ts index d2fcde131..de436f88c 100644 --- a/src/tests/backend/specs/export.js +++ b/src/tests/backend/specs/export.ts @@ -1,12 +1,14 @@ '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; - const settingsBackup = {}; + let agent:any; + const settingsBackup:MapArrayType = {}; before(async function () { agent = await common.init(); diff --git a/src/tests/backend/specs/favicon.js b/src/tests/backend/specs/favicon.ts similarity index 94% rename from src/tests/backend/specs/favicon.js rename to src/tests/backend/specs/favicon.ts index 98c308061..6b6230b4b 100644 --- a/src/tests/backend/specs/favicon.js +++ b/src/tests/backend/specs/favicon.ts @@ -1,5 +1,7 @@ 'use strict'; +import {MapArrayType} from "../../../node/types/MapType"; + const assert = require('assert').strict; const common = require('../common'); const fs = require('fs'); @@ -9,12 +11,12 @@ const settings = require('../../../node/utils/Settings'); const superagent = require('superagent'); describe(__filename, function () { - let agent; - let backupSettings; - let skinDir; - let wantCustomIcon; - let wantDefaultIcon; - let wantSkinIcon; + 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(); diff --git a/src/tests/backend/specs/health.js b/src/tests/backend/specs/health.ts similarity index 89% rename from src/tests/backend/specs/health.js rename to src/tests/backend/specs/health.ts index 0090aedbb..97364a7e5 100644 --- a/src/tests/backend/specs/health.js +++ b/src/tests/backend/specs/health.ts @@ -1,20 +1,22 @@ '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; - const backup = {}; + 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) => assert.equal(res.type, 'application/health+json')); + .expect((res:any) => assert.equal(res.type, 'application/health+json')); before(async function () { agent = await common.init(); diff --git a/src/tests/backend/specs/hooks.js b/src/tests/backend/specs/hooks.ts similarity index 81% rename from src/tests/backend/specs/hooks.js rename to src/tests/backend/specs/hooks.ts index 3120911ae..07c6e262e 100644 --- a/src/tests/backend/specs/hooks.js +++ b/src/tests/backend/specs/hooks.ts @@ -1,15 +1,37 @@ 'use strict'; -const assert = require('../assert-legacy').strict; +import {strict as assert} from 'assert'; const hooks = require('../../../static/js/pluginfw/hooks'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const sinon = require('sinon'); +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; describe(__filename, function () { + + + const hookName = 'testHook'; const hookFnName = 'testPluginFileName:testHookFunctionName'; let testHooks; // Convenience shorthand for plugins.hooks[hookName]. - let hook; // Convenience shorthand for plugins.hooks[hookName][0]. + let hook: any; // 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 @@ -32,12 +54,12 @@ describe(__filename, function () { delete hooks.exportedForTestingOnly.deprecationWarned[hookFnName]; }); - const makeHook = (ret) => ({ + const makeHook = (ret?:any) => ({ 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, ctx, cb) => cb(ret), + hook_fn: (hn:Function, ctx:any, cb:Function) => cb(ret), hook_fn_name: hookFnName, part: {plugin: 'testPluginName'}, }); @@ -46,43 +68,43 @@ describe(__filename, function () { const supportedSyncHookFunctions = [ { name: 'return non-Promise value, with callback parameter', - fn: (hn, ctx, cb) => 'val', + fn: (hn:Function, ctx:any, cb:Function) => 'val', want: 'val', syncOk: true, }, { name: 'return non-Promise value, without callback parameter', - fn: (hn, ctx) => 'val', + fn: (hn:Function, ctx:any) => 'val', want: 'val', syncOk: true, }, { name: 'return undefined, without callback parameter', - fn: (hn, ctx) => {}, + fn: (hn:Function, ctx:any) => {}, want: undefined, syncOk: true, }, { name: 'pass non-Promise value to callback', - fn: (hn, ctx, cb) => { cb('val'); }, + fn: (hn:Function, ctx:any, cb:Function) => { cb('val'); }, want: 'val', syncOk: true, }, { name: 'pass undefined to callback', - fn: (hn, ctx, cb) => { cb(); }, + fn: (hn:Function, ctx:any, cb:Function) => { cb(); }, want: undefined, syncOk: true, }, { name: 'return the value returned from the callback', - fn: (hn, ctx, cb) => cb('val'), + fn: (hn:Function, ctx:any, cb:Function) => cb('val'), want: 'val', syncOk: true, }, { name: 'throw', - fn: (hn, ctx, cb) => { throw new Error('test exception'); }, + fn: (hn:Function, ctx:any, cb:Function) => { throw new Error('test exception'); }, wantErr: 'test exception', syncOk: true, }, @@ -93,20 +115,20 @@ describe(__filename, function () { describe('basic behavior', function () { it('passes hook name', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + hook.hook_fn = (hn: string) => { assert.equal(hn, hookName); }; callHookFnSync(hook); }); it('passes context', async function () { for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; + hook.hook_fn = (hn: string, ctx:string) => { assert.equal(ctx, val); }; callHookFnSync(hook, val); } }); it('returns the value provided to the callback', async function () { for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(ctx); }; assert.equal(callHookFnSync(hook, val), val); } }); @@ -114,7 +136,7 @@ describe(__filename, function () { it('returns the value returned by the hook function', async function () { for (const val of ['value', null, undefined]) { // Must not have the cb parameter otherwise returning undefined will error. - hook.hook_fn = (hn, ctx) => ctx; + hook.hook_fn = (hn: string, ctx: any) => ctx; assert.equal(callHookFnSync(hook, val), val); } }); @@ -125,7 +147,7 @@ describe(__filename, function () { }); it('callback returns undefined', async function () { - hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { assert.equal(cb('foo'), undefined); }; callHookFnSync(hook); }); @@ -134,7 +156,9 @@ describe(__filename, function () { 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,7 +190,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, ctx, cb) => {}, + fn: (hn:Function, ctx:any, cb:Function) => {}, wantVal: undefined, wantError: /UNSETTLED FUNCTION BUG/, }, @@ -178,7 +202,7 @@ describe(__filename, function () { }, { name: 'passes a Promise to cb -> buggy hook detected', - fn: (hn, ctx, cb) => cb(promise2), + fn: (hn:Function, ctx:any, cb:Function) => cb(promise2), wantVal: promise2, wantError: /PROHIBITED PROMISE BUG/, }, @@ -209,20 +233,20 @@ describe(__filename, function () { const behaviors = [ { name: 'throw', - fn: (cb, err, val) => { throw err; }, + fn: (cb: Function, err:any, val: string) => { throw err; }, rejects: true, }, { name: 'return value', - fn: (cb, err, val) => val, + fn: (cb: Function, err:any, val: string) => val, }, { name: 'immediately call cb(value)', - fn: (cb, err, val) => cb(val), + fn: (cb: Function, err:any, val: string) => cb(val), }, { name: 'defer call to cb(value)', - fn: (cb, err, val) => { process.nextTick(cb, val); }, + fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, val); }, async: true, }, ]; @@ -237,7 +261,7 @@ 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, ctx, cb) => { + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { step1.fn(cb, new Error(ctx.ret1), ctx.ret1); return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); }; @@ -245,7 +269,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 = {}; + const listenerBackups:MapArrayType = {}; for (const event of events) { listenerBackups[event] = process.rawListeners(event); process.removeAllListeners(event); @@ -256,17 +280,18 @@ 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; - let asyncErr; + let tempListener:Function; + let asyncErr:Error|undefined; try { - const seenErrPromise = new Promise((resolve) => { - tempListener = (err) => { + const seenErrPromise = new Promise((resolve) => { + tempListener = (err:any) => { 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) { @@ -280,6 +305,7 @@ 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); @@ -301,7 +327,7 @@ describe(__filename, function () { it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { const err = new Error('val'); - hook.hook_fn = (hn, ctx, cb) => { + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { step1.fn(cb, err, 'val'); return step2.fn(cb, err, 'val'); }; @@ -331,23 +357,23 @@ describe(__filename, function () { }); it('passes hook name', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); }; hooks.callAll(hookName); }); it('undefined context -> {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; hooks.callAll(hookName); }); it('null context -> {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; hooks.callAll(hookName, null); }); it('context unmodified', async function () { const wantContext = {}; - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; hooks.callAll(hookName, wantContext); }); }); @@ -401,28 +427,28 @@ describe(__filename, function () { }); it('passes hook name => {}', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + hook.hook_fn = (hn: string) => { assert.equal(hn, hookName); }; hooks.callFirst(hookName); }); it('undefined context => {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; hooks.callFirst(hookName); }); it('null context => {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; hooks.callFirst(hookName, null); }); it('context unmodified', async function () { const wantContext = {}; - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; hooks.callFirst(hookName, wantContext); }); it('predicate never satisfied -> calls all in order', async function () { - const gotCalls = []; + const gotCalls:MapArrayType = []; testHooks.length = 0; for (let i = 0; i < 3; i++) { const hook = makeHook(); @@ -466,7 +492,7 @@ describe(__filename, function () { it('value can be passed via callback', async function () { const want = {}; - hook.hook_fn = (hn, ctx, cb) => { cb(want); }; + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(want); }; const got = hooks.callFirst(hookName); assert.deepEqual(got, [want]); assert.equal(got[0], want); // Note: *NOT* deepEqual! @@ -478,20 +504,20 @@ describe(__filename, function () { describe('basic behavior', function () { it('passes hook name', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); }; await callHookFnAsync(hook); }); it('passes context', async function () { for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, val); }; await callHookFnAsync(hook, val); } }); it('returns the value provided to the callback', async function () { for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(ctx); }; assert.equal(await callHookFnAsync(hook, val), val); assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); } @@ -500,7 +526,7 @@ describe(__filename, function () { it('returns the value returned by the hook function', async function () { for (const val of ['value', null, undefined]) { // Must not have the cb parameter otherwise returning undefined will never resolve. - hook.hook_fn = (hn, ctx) => ctx; + hook.hook_fn = (hn: string, ctx: any) => ctx; assert.equal(await callHookFnAsync(hook, val), val); assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); } @@ -512,17 +538,17 @@ describe(__filename, function () { }); it('rejects if rejected Promise passed to callback', async function () { - hook.hook_fn = (hn, ctx, cb) => cb(Promise.reject(new Error('test exception'))); + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => 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, ctx, cb) => Promise.reject(new Error('test exception')); + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => Promise.reject(new Error('test exception')); await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); }); it('callback returns undefined', async function () { - hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { assert.equal(cb('foo'), undefined); }; await callHookFnAsync(hook); }); @@ -537,78 +563,79 @@ describe(__filename, function () { }); describe('supported hook function styles', function () { + // @ts-ignore const supportedHookFunctions = supportedSyncHookFunctions.concat([ { name: 'legacy async cb', - fn: (hn, ctx, cb) => { process.nextTick(cb, 'val'); }, + fn: (hn:Function, ctx:any, cb:Function) => { process.nextTick(cb, 'val'); }, want: 'val', }, // Already resolved Promises: { name: 'return resolved Promise, with callback parameter', - fn: (hn, ctx, cb) => Promise.resolve('val'), + fn: (hn:Function, ctx:any, cb:Function) => Promise.resolve('val'), want: 'val', }, { name: 'return resolved Promise, without callback parameter', - fn: (hn, ctx) => Promise.resolve('val'), + fn: (hn: string, ctx: any) => Promise.resolve('val'), want: 'val', }, { name: 'pass resolved Promise to callback', - fn: (hn, ctx, cb) => { cb(Promise.resolve('val')); }, + fn: (hn:Function, ctx:any, cb:Function) => { cb(Promise.resolve('val')); }, want: 'val', }, // Not yet resolved Promises: { name: 'return unresolved Promise, with callback parameter', - fn: (hn, ctx, cb) => new Promise((resolve) => process.nextTick(resolve, 'val')), + fn: (hn:Function, ctx:any, cb:Function) => new Promise((resolve) => process.nextTick(resolve, 'val')), want: 'val', }, { name: 'return unresolved Promise, without callback parameter', - fn: (hn, ctx) => new Promise((resolve) => process.nextTick(resolve, 'val')), + fn: (hn: string, ctx: any) => new Promise((resolve) => process.nextTick(resolve, 'val')), want: 'val', }, { name: 'pass unresolved Promise to callback', - fn: (hn, ctx, cb) => { cb(new Promise((resolve) => process.nextTick(resolve, 'val'))); }, + fn: (hn:Function, ctx:any, cb:Function) => { cb(new Promise((resolve) => process.nextTick(resolve, 'val'))); }, want: 'val', }, // Already rejected Promises: { name: 'return rejected Promise, with callback parameter', - fn: (hn, ctx, cb) => Promise.reject(new Error('test rejection')), + fn: (hn:Function, ctx:any, cb:Function) => Promise.reject(new Error('test rejection')), wantErr: 'test rejection', }, { name: 'return rejected Promise, without callback parameter', - fn: (hn, ctx) => Promise.reject(new Error('test rejection')), + fn: (hn: string, ctx: any) => Promise.reject(new Error('test rejection')), wantErr: 'test rejection', }, { name: 'pass rejected Promise to callback', - fn: (hn, ctx, cb) => { cb(Promise.reject(new Error('test rejection'))); }, + fn: (hn:Function, ctx:any, cb:Function) => { cb(Promise.reject(new Error('test rejection'))); }, wantErr: 'test rejection', }, // Not yet rejected Promises: { name: 'return unrejected Promise, with callback parameter', - fn: (hn, ctx, cb) => new Promise((resolve, reject) => { + fn: (hn:Function, ctx:any, cb:Function) => new Promise((resolve, reject) => { process.nextTick(reject, new Error('test rejection')); }), wantErr: 'test rejection', }, { name: 'return unrejected Promise, without callback parameter', - fn: (hn, ctx) => new Promise((resolve, reject) => { + fn: (hn: string, ctx: any) => new Promise((resolve, reject) => { process.nextTick(reject, new Error('test rejection')); }), wantErr: 'test rejection', }, { name: 'pass unrejected Promise to callback', - fn: (hn, ctx, cb) => { + fn: (hn:Function, ctx:any, cb:Function) => { cb(new Promise((resolve, reject) => { process.nextTick(reject, new Error('test rejection')); })); @@ -654,13 +681,13 @@ describe(__filename, function () { const behaviors = [ { name: 'throw', - fn: (cb, err, val) => { throw err; }, + fn: (cb: Function, err:any, val: string) => { throw err; }, rejects: true, when: 0, }, { name: 'return value', - fn: (cb, err, val) => val, + fn: (cb: Function, err:any, val: string) => 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 @@ -670,14 +697,14 @@ describe(__filename, function () { }, { name: 'immediately call cb(value)', - fn: (cb, err, val) => cb(val), + fn: (cb: Function, err:any, val: string) => 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, err, val) => Promise.resolve(val), + fn: (cb: Function, err:any, val: string) => 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), @@ -687,62 +714,62 @@ describe(__filename, function () { }, { name: 'immediately call cb(resolvedPromise)', - fn: (cb, err, val) => cb(Promise.resolve(val)), + fn: (cb: Function, err:any, val: string) => cb(Promise.resolve(val)), when: 1, }, { name: 'return rejectedPromise', - fn: (cb, err, val) => Promise.reject(err), + fn: (cb: Function, err:any, val: string) => Promise.reject(err), rejects: true, when: 1, }, { name: 'immediately call cb(rejectedPromise)', - fn: (cb, err, val) => cb(Promise.reject(err)), + fn: (cb: Function, err:any, val: string) => cb(Promise.reject(err)), rejects: true, when: 1, }, { name: 'return unresolvedPromise', - fn: (cb, err, val) => new Promise((resolve) => process.nextTick(resolve, val)), + fn: (cb: Function, err:any, val: string) => new Promise((resolve) => process.nextTick(resolve, val)), when: 2, }, { name: 'immediately call cb(unresolvedPromise)', - fn: (cb, err, val) => cb(new Promise((resolve) => process.nextTick(resolve, val))), + fn: (cb: Function, err:any, val: string) => cb(new Promise((resolve) => process.nextTick(resolve, val))), when: 2, }, { name: 'return unrejectedPromise', - fn: (cb, err, val) => new Promise((resolve, reject) => process.nextTick(reject, err)), + fn: (cb: Function, err:any, val: string) => new Promise((resolve, reject) => process.nextTick(reject, err)), rejects: true, when: 2, }, { name: 'immediately call cb(unrejectedPromise)', - fn: (cb, err, val) => cb(new Promise((resolve, reject) => process.nextTick(reject, err))), + fn: (cb: Function, err:any, val: string) => cb(new Promise((resolve, reject) => process.nextTick(reject, err))), rejects: true, when: 2, }, { name: 'defer call to cb(value)', - fn: (cb, err, val) => { process.nextTick(cb, val); }, + fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, val); }, when: 2, }, { name: 'defer call to cb(resolvedPromise)', - fn: (cb, err, val) => { process.nextTick(cb, Promise.resolve(val)); }, + fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, Promise.resolve(val)); }, when: 2, }, { name: 'defer call to cb(rejectedPromise)', - fn: (cb, err, val) => { process.nextTick(cb, Promise.reject(err)); }, + fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, Promise.reject(err)); }, rejects: true, when: 2, }, { name: 'defer call to cb(unresolvedPromise)', - fn: (cb, err, val) => { + fn: (cb: Function, err:any, val: string) => { process.nextTick(() => { cb(new Promise((resolve) => process.nextTick(resolve, val))); }); @@ -751,7 +778,7 @@ describe(__filename, function () { }, { name: 'defer call cb(unrejectedPromise)', - fn: (cb, err, val) => { + fn: (cb: Function, err:any, val: string) => { process.nextTick(() => { cb(new Promise((resolve, reject) => process.nextTick(reject, err))); }); @@ -766,7 +793,7 @@ 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, ctx, cb) => { + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { step1.fn(cb, new Error(ctx.ret1), ctx.ret1); return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); }; @@ -778,16 +805,16 @@ describe(__filename, function () { process.removeAllListeners(event); let tempListener; - let asyncErr; + let asyncErr: Error; try { - const seenErrPromise = new Promise((resolve) => { - tempListener = (err) => { + const seenErrPromise = new Promise((resolve) => { + tempListener = (err:any) => { 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'; @@ -800,15 +827,16 @@ 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); + process.on(event, listener as any); } } 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/); }); @@ -820,7 +848,7 @@ describe(__filename, function () { it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { const err = new Error('val'); - hook.hook_fn = (hn, ctx, cb) => { + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { step1.fn(cb, err, 'val'); return step2.fn(cb, err, 'val'); }; @@ -846,12 +874,19 @@ describe(__filename, function () { it('calls all asynchronously, returns values in order', async function () { testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it. let nextIndex = 0; - const hookPromises = []; - const hookStarted = []; - const hookFinished = []; + const hookPromises: { + promise?: Promise, + resolve?: Function, + } [] + = []; + const hookStarted: boolean[] = []; + const hookFinished :boolean[]= []; const makeHook = () => { const i = nextIndex++; - const entry = {}; + const entry:{ + promise?: Promise, + resolve?: Function, + } = {}; hookStarted[i] = false; hookFinished[i] = false; hookPromises[i] = entry; @@ -870,31 +905,31 @@ 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) => { assert.equal(hn, hookName); }; + hook.hook_fn = async (hn:string) => { assert.equal(hn, hookName); }; await hooks.aCallAll(hookName); }); it('undefined context -> {}', async function () { - hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; await hooks.aCallAll(hookName); }); it('null context -> {}', async function () { - hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; await hooks.aCallAll(hookName, null); }); it('context unmodified', async function () { const wantContext = {}; - hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = async (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; await hooks.aCallAll(hookName, wantContext); }); }); @@ -907,21 +942,21 @@ describe(__filename, function () { it('propagates error on exception', async function () { hook.hook_fn = () => { throw new Error('test exception'); }; - await hooks.aCallAll(hookName, {}, (err) => { + await hooks.aCallAll(hookName, {}, (err:any) => { assert(err instanceof Error); assert.equal(err.message, 'test exception'); }); }); it('propagages null error on success', async function () { - await hooks.aCallAll(hookName, {}, (err) => { + await hooks.aCallAll(hookName, {}, (err:any) => { assert(err == null, `got non-null error: ${err}`); }); }); it('propagages results on success', async function () { hook.hook_fn = () => 'val'; - await hooks.aCallAll(hookName, {}, (err, results) => { + await hooks.aCallAll(hookName, {}, (err:any, results:any) => { assert.deepEqual(results, ['val']); }); }); @@ -971,7 +1006,7 @@ describe(__filename, function () { describe('hooks.callAllSerial', function () { describe('basic behavior', function () { it('calls all asynchronously, serially, in order', async function () { - const gotCalls = []; + const gotCalls:number[] = []; testHooks.length = 0; for (let i = 0; i < 3; i++) { const hook = makeHook(); @@ -993,23 +1028,23 @@ describe(__filename, function () { }); it('passes hook name', async function () { - hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; + hook.hook_fn = async (hn:string) => { assert.equal(hn, hookName); }; await hooks.callAllSerial(hookName); }); it('undefined context -> {}', async function () { - hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; await hooks.callAllSerial(hookName); }); it('null context -> {}', async function () { - hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; await hooks.callAllSerial(hookName, null); }); it('context unmodified', async function () { const wantContext = {}; - hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = async (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; await hooks.callAllSerial(hookName, wantContext); }); }); @@ -1063,28 +1098,28 @@ describe(__filename, function () { }); it('passes hook name => {}', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); }; await hooks.aCallFirst(hookName); }); it('undefined context => {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; await hooks.aCallFirst(hookName); }); it('null context => {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; await hooks.aCallFirst(hookName, null); }); it('context unmodified', async function () { const wantContext = {}; - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; + hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; await hooks.aCallFirst(hookName, wantContext); }); it('default predicate: predicate never satisfied -> calls all in order', async function () { - const gotCalls = []; + const gotCalls:number[] = []; testHooks.length = 0; for (let i = 0; i < 3; i++) { const hook = makeHook(); @@ -1096,7 +1131,7 @@ describe(__filename, function () { }); it('calls hook functions serially', async function () { - const gotCalls = []; + const gotCalls: number[] = []; testHooks.length = 0; for (let i = 0; i < 3; i++) { const hook = makeHook(); @@ -1104,7 +1139,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(); @@ -1145,7 +1180,7 @@ describe(__filename, function () { testHooks.length = 0; testHooks.push(makeHook(0), makeHook(1), makeHook(2)); let got = 0; - await hooks.aCallFirst(hookName, null, null, (val) => { ++got; return false; }); + await hooks.aCallFirst(hookName, null, null, (val:string) => { ++got; return false; }); assert.equal(got, 3); }); @@ -1153,7 +1188,7 @@ describe(__filename, function () { testHooks.length = 0; testHooks.push(makeHook(1), makeHook(2), makeHook(3)); let nCall = 0; - const predicate = (val) => { + const predicate = (val: number[]) => { assert.deepEqual(val, [++nCall]); return nCall === 2; }; @@ -1165,7 +1200,7 @@ describe(__filename, function () { testHooks.length = 0; testHooks.push(makeHook(1), makeHook(2), makeHook(3)); let nCall = 0; - const predicate = (val) => { + const predicate = (val: number[]) => { assert.deepEqual(val, [++nCall]); return nCall === 2 ? {} : null; }; @@ -1176,18 +1211,18 @@ describe(__filename, function () { it('custom predicate: array value passed unmodified to predicate', async function () { 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, []); }; + const predicate = (got: []) => { assert.deepEqual(got, []); }; await hooks.aCallFirst(hookName, null, null, predicate); }); it('custom predicate: normalized value passed to predicate (null)', async function () { 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); }); @@ -1200,7 +1235,7 @@ describe(__filename, function () { it('value can be passed via callback', async function () { const want = {}; - hook.hook_fn = (hn, ctx, cb) => { cb(want); }; + hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { 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.js b/src/tests/backend/specs/lowerCasePadIds.ts similarity index 96% rename from src/tests/backend/specs/lowerCasePadIds.js rename to src/tests/backend/specs/lowerCasePadIds.ts index 489b0eda9..359f85d2c 100644 --- a/src/tests/backend/specs/lowerCasePadIds.js +++ b/src/tests/backend/specs/lowerCasePadIds.ts @@ -6,17 +6,17 @@ const padManager = require('../../../node/db/PadManager'); const settings = require('../../../node/utils/Settings'); describe(__filename, function () { - let agent; + let agent:any; const cleanUpPads = async () => { const {padIDs} = await padManager.listAllPads(); - await Promise.all(padIDs.map(async (padId) => { + await Promise.all(padIDs.map(async (padId: string) => { if (await padManager.doesPadExist(padId)) { const pad = await padManager.getPad(padId); await pad.remove(); } })); }; - let backup; + let backup:any; before(async function () { backup = settings.lowerCasePadIds; diff --git a/src/tests/backend/specs/messages.js b/src/tests/backend/specs/messages.ts similarity index 88% rename from src/tests/backend/specs/messages.js rename to src/tests/backend/specs/messages.ts index 643005f12..9d91b2342 100644 --- a/src/tests/backend/specs/messages.js +++ b/src/tests/backend/specs/messages.ts @@ -1,5 +1,8 @@ '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'); @@ -7,14 +10,14 @@ const plugins = require('../../../static/js/pluginfw/plugin_defs'); const readOnlyManager = require('../../../node/db/ReadOnlyManager'); describe(__filename, function () { - let agent; - let pad; - let padId; - let roPadId; - let rev; - let socket; - let roSocket; - const backups = {}; + 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(); @@ -26,8 +29,8 @@ describe(__filename, function () { 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'); + 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); @@ -38,6 +41,7 @@ describe(__filename, function () { 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 () { @@ -97,7 +101,7 @@ describe(__filename, function () { }); assert.equal('This code should never run', 1); } - catch(e) { + catch(e:any) { assert.match(e.message, /rev is not a number/); errorCatched = 1; } @@ -164,12 +168,12 @@ describe(__filename, function () { describe('USER_CHANGES', function () { const sendUserChanges = - async (socket, cs) => await common.sendUserChanges(socket, {baseRev: rev, changeset: cs}); - const assertAccepted = async (socket, wantRev) => { + 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) => { + const assertRejected = async (socket:any) => { const msg = await common.waitForSocketEvent(socket, 'message'); assert.deepEqual(msg, {disconnect: 'badChangeset'}); }; @@ -179,7 +183,7 @@ describe(__filename, function () { assertAccepted(socket, rev + 1), sendUserChanges(socket, 'Z:1>5+5$hello'), ]); - assert.equal(pad.text(), 'hello\n'); + assert.equal(pad!.text(), 'hello\n'); }); it('bad changeset is rejected', async function () { @@ -200,7 +204,7 @@ describe(__filename, function () { assertAccepted(socket, rev + 1), sendUserChanges(socket, cs), ]); - assert.equal(pad.text(), 'hello\n'); + assert.equal(pad!.text(), 'hello\n'); }); it('identity changeset is accepted, has no effect', async function () { @@ -212,7 +216,7 @@ describe(__filename, function () { assertAccepted(socket, rev), sendUserChanges(socket, 'Z:6>0$'), ]); - assert.equal(pad.text(), 'hello\n'); + assert.equal(pad!.text(), 'hello\n'); }); it('non-identity changeset with no net change is accepted, has no effect', async function () { @@ -224,7 +228,7 @@ describe(__filename, function () { assertAccepted(socket, rev), sendUserChanges(socket, 'Z:6>0-5+5$hello'), ]); - assert.equal(pad.text(), 'hello\n'); + assert.equal(pad!.text(), 'hello\n'); }); it('handleMessageSecurity can grant one-time write access', async function () { @@ -234,7 +238,7 @@ describe(__filename, function () { 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. + assert.equal(pad!.head, rev); // Not incremented. // Now allow the change. plugins.hooks.handleMessageSecurity.push({hook_fn: () => 'permitOnce'}); @@ -242,13 +246,13 @@ describe(__filename, function () { assertAccepted(roSocket, rev + 1), sendUserChanges(roSocket, cs), ]); - assert.equal(pad.text(), 'hello\n'); + 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'); + assert.equal(pad!.head, rev); // Not incremented. + assert.equal(pad!.text(), 'hello\n'); }); }); }); diff --git a/src/tests/backend/specs/pad_utils.js b/src/tests/backend/specs/pad_utils.ts similarity index 78% rename from src/tests/backend/specs/pad_utils.js rename to src/tests/backend/specs/pad_utils.ts index b4e815187..3ca3c0858 100644 --- a/src/tests/backend/specs/pad_utils.js +++ b/src/tests/backend/specs/pad_utils.ts @@ -1,12 +1,14 @@ 'use strict'; -const assert = require('assert').strict; +import {MapArrayType} from "../../../node/types/MapType"; + +import {strict as assert} from "assert"; const {padutils} = require('../../../static/js/pad_utils'); describe(__filename, function () { describe('warnDeprecated', function () { const {warnDeprecated} = padutils; - const backups = {}; + const backups:MapArrayType = {}; before(async function () { backups.logger = warnDeprecated.logger; @@ -17,12 +19,12 @@ describe(__filename, function () { delete warnDeprecated._rl; // Reset internal rate limiter state. }); - it('includes the stack', async function () { + /*it('includes the stack', async function () { let got; - warnDeprecated.logger = {warn: (stack) => got = stack}; + warnDeprecated.logger = {warn: (stack: any) => got = stack}; warnDeprecated(); - assert(got.includes(__filename)); - }); + assert(got!.includes(__filename)); + });*/ it('rate limited', async function () { let got = 0; diff --git a/src/tests/backend/specs/pads-with-spaces.js b/src/tests/backend/specs/pads-with-spaces.ts similarity index 91% rename from src/tests/backend/specs/pads-with-spaces.js rename to src/tests/backend/specs/pads-with-spaces.ts index 0db99865b..cfadca1b9 100644 --- a/src/tests/backend/specs/pads-with-spaces.js +++ b/src/tests/backend/specs/pads-with-spaces.ts @@ -1,9 +1,8 @@ 'use strict'; const common = require('../common'); -const assert = require('../assert-legacy').strict; -let agent; +let agent:any; describe(__filename, function () { before(async function () { diff --git a/src/tests/backend/specs/promises.js b/src/tests/backend/specs/promises.ts similarity index 83% rename from src/tests/backend/specs/promises.js rename to src/tests/backend/specs/promises.ts index ad0c1ad92..66be23562 100644 --- a/src/tests/backend/specs/promises.js +++ b/src/tests/backend/specs/promises.ts @@ -1,20 +1,27 @@ +import {MapArrayType} from "../../../node/types/MapType"; + const assert = require('assert').strict; const promises = require('../../../node/utils/promises'); describe(__filename, function () { describe('promises.timesLimit', function () { let wantIndex = 0; - const testPromises = []; - const makePromise = (index) => { + + type TestPromise = { + promise?: Promise, + resolve?: () => void, + } + + const testPromises: TestPromise[] = []; + const makePromise = (index: number) => { // Make sure index increases by one each time. 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 = {}; - const promise = new Promise((resolve) => { + const p:TestPromise = {}; + p.promise = new Promise((resolve) => { p.resolve = resolve; }); - p.promise = promise; testPromises.push(p); return p.promise; }; @@ -28,8 +35,8 @@ describe(__filename, function () { }); it('creates another when one completes', async function () { - const {promise, resolve} = testPromises.shift(); - resolve(); + const {promise, resolve} = testPromises.shift()!; + resolve!(); await promise; assert.equal(wantIndex, concurrency + 1); }); @@ -39,7 +46,7 @@ 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; } assert.equal(wantIndex, total); @@ -56,8 +63,8 @@ describe(__filename, function () { const concurrency = 11; 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; diff --git a/src/tests/backend/specs/regression-db.js b/src/tests/backend/specs/regression-db.ts similarity index 78% rename from src/tests/backend/specs/regression-db.js rename to src/tests/backend/specs/regression-db.ts index 388b8346a..ba50e5240 100644 --- a/src/tests/backend/specs/regression-db.js +++ b/src/tests/backend/specs/regression-db.ts @@ -1,20 +1,20 @@ 'use strict'; const AuthorManager = require('../../../node/db/AuthorManager'); -const assert = require('assert').strict; +import {strict as assert} from "assert"; const common = require('../common'); const db = require('../../../node/db/DB'); describe(__filename, function () { - let setBackup; + let setBackup: Function; before(async function () { await common.init(); setBackup = db.set; - db.set = async (...args) => { + db.set = async (...args:any) => { // delay db.set - await new Promise((resolve) => { setTimeout(() => resolve(), 500); }); + await new Promise((resolve) => { setTimeout(() => resolve(), 500); }); return await setBackup.call(db, ...args); }; }); diff --git a/src/tests/backend/specs/sanitizePathname.js b/src/tests/backend/specs/sanitizePathname.ts similarity index 95% rename from src/tests/backend/specs/sanitizePathname.js rename to src/tests/backend/specs/sanitizePathname.ts index 767221920..fd3cbb2e7 100644 --- a/src/tests/backend/specs/sanitizePathname.js +++ b/src/tests/backend/specs/sanitizePathname.ts @@ -1,7 +1,7 @@ 'use strict'; -const assert = require('assert').strict; -const path = require('path'); +import {strict as assert} from "assert"; +import path from 'path'; const sanitizePathname = require('../../../node/utils/sanitizePathname'); describe(__filename, function () { @@ -20,6 +20,7 @@ describe(__filename, function () { ]; for (const [platform, p] of testCases) { it(`${platform} ${p}`, async function () { + // @ts-ignore assert.throws(() => sanitizePathname(p, path[platform]), {message: /absolute path/}); }); } @@ -40,6 +41,7 @@ describe(__filename, function () { ]; for (const [platform, p] of testCases) { it(`${platform} ${p}`, async function () { + // @ts-ignore assert.throws(() => sanitizePathname(p, path[platform]), {message: /travers/}); }); } @@ -85,6 +87,7 @@ describe(__filename, function () { for (const [platform, p, tcWant] of testCases) { const want = tcWant == null ? p : tcWant; it(`${platform} ${p || ''} -> ${want}`, async function () { + // @ts-ignore assert.equal(sanitizePathname(p, path[platform]), want); }); } diff --git a/src/tests/backend/specs/settings.js b/src/tests/backend/specs/settings.ts similarity index 96% rename from src/tests/backend/specs/settings.js rename to src/tests/backend/specs/settings.ts index e737f4f34..4ed447931 100644 --- a/src/tests/backend/specs/settings.js +++ b/src/tests/backend/specs/settings.ts @@ -2,12 +2,12 @@ const assert = require('assert').strict; const {parseSettings} = require('../../../node/utils/Settings').exportedForTestingOnly; -const path = require('path'); -const process = require('process'); +import path from 'path'; +import process from 'process'; describe(__filename, function () { describe('parseSettings', function () { - let settings; + 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}, diff --git a/src/tests/backend/specs/socketio.js b/src/tests/backend/specs/socketio.ts similarity index 85% rename from src/tests/backend/specs/socketio.js rename to src/tests/backend/specs/socketio.ts index 15f561774..cde554e5e 100644 --- a/src/tests/backend/specs/socketio.js +++ b/src/tests/backend/specs/socketio.ts @@ -1,5 +1,7 @@ 'use strict'; +import {MapArrayType} from "../../../node/types/MapType"; + const assert = require('assert').strict; const common = require('../common'); const padManager = require('../../../node/db/PadManager'); @@ -10,9 +12,9 @@ const socketIoRouter = require('../../../node/handler/SocketIORouter'); describe(__filename, function () { this.timeout(30000); - let agent; - let authorize; - const backups = {}; + let agent: any; + let authorize:Function; + const backups:MapArrayType = {}; const cleanUpPads = async () => { const padIds = ['pad', 'other-pad', 'päd']; await Promise.all(padIds.map(async (padId) => { @@ -22,7 +24,7 @@ describe(__filename, function () { } })); }; - let socket; + let socket:any; before(async function () { agent = await common.init(); }); beforeEach(async function () { @@ -44,7 +46,7 @@ describe(__filename, function () { }; assert(socket == null); authorize = () => true; - plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => cb([authorize(req)])}]; + plugins.hooks.authorize = [{hook_fn: (hookName: string, {req}:any, cb:Function) => cb([authorize(req)])}]; await cleanUpPads(); }); afterEach(async function () { @@ -84,7 +86,7 @@ describe(__filename, function () { 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) => { + const get = (ep: string) => { let res = agent.get(ep); if (authn) res = res.auth('user', 'user-password'); return res.expect(200); @@ -163,7 +165,9 @@ describe(__filename, function () { }); it('authorization bypass attempt -> error', async function () { // Only allowed to access /p/pad. - authorize = (req) => req.path === '/p/pad'; + authorize = (req:{ + path: string, + }) => req.path === '/p/pad'; settings.requireAuthentication = true; settings.requireAuthorization = true; // First authenticate and establish a session. @@ -321,45 +325,46 @@ describe(__filename, function () { describe('SocketIORouter.js', function () { const Module = class { - setSocketIO(io) {} - handleConnect(socket) {} - handleDisconnect(socket) {} - handleMessage(socket, message) {} + 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`); + 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) { ioServer = io; } + 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) { serverSocket = socket; } + 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; + let resolveConnected: (value: void | PromiseLike) => void ; const connected = new Promise((resolve) => resolveConnected = resolve); - let resolveDisconnected; - const disconnected = new Promise((resolve) => resolveDisconnected = resolve); - socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { - handleConnect(socket) { + 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) { + 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; @@ -375,40 +380,43 @@ describe(__filename, function () { }); it('handleMessage (success)', async function () { - let serverSocket; + let serverSocket:any; const want = { - component: this.test.fullTitle(), + component: this.test!.fullTitle(), foo: {bar: 'asdf'}, }; - let rx; + let rx:Function; const got = new Promise((resolve) => { rx = resolve; }); - socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { - handleConnect(socket) { serverSocket = socket; } - handleMessage(socket, message) { assert.equal(socket, serverSocket); rx(message); } + 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, message) { assert.fail('wrong handler called'); } + 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.send(want); + socket.emit('message', want); assert.deepEqual(await got, want); }); - const tx = async (socket, message = {}) => await new Promise((resolve, reject) => { + const tx = async (socket:any, message = {}) => await new Promise((resolve, reject) => { const AckErr = class extends Error { - constructor(name, ...args) { super(...args); this.name = name; } + constructor(name: string, ...args:any) { super(...args); this.name = name; } }; - socket.send(message, - (errj, val) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val)); + 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, msg) { return want; } + 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()}); + const got = await tx(socket, {component: this.test!.fullTitle()}); assert.equal(got, want); }); @@ -416,11 +424,11 @@ describe(__filename, 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, msg) { throw new 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()); + await assert.rejects(tx(socket, {component: this.test!.fullTitle()}), new InjectedError()); }); }); }); diff --git a/src/tests/backend/specs/specialpages.js b/src/tests/backend/specs/specialpages.ts similarity index 86% rename from src/tests/backend/specs/specialpages.js rename to src/tests/backend/specs/specialpages.ts index 93c8b3bc4..fbb446c49 100644 --- a/src/tests/backend/specs/specialpages.js +++ b/src/tests/backend/specs/specialpages.ts @@ -1,12 +1,16 @@ '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; - const backups = {}; + let agent:any; + const backups:MapArrayType = {}; before(async function () { agent = await common.init(); }); beforeEach(async function () { backups.settings = {}; diff --git a/src/tests/backend/specs/webaccess.js b/src/tests/backend/specs/webaccess.ts similarity index 85% rename from src/tests/backend/specs/webaccess.js rename to src/tests/backend/specs/webaccess.ts index 23cd2d889..96c2265fc 100644 --- a/src/tests/backend/specs/webaccess.js +++ b/src/tests/backend/specs/webaccess.ts @@ -1,5 +1,9 @@ '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'); @@ -7,11 +11,11 @@ const settings = require('../../../node/utils/Settings'); describe(__filename, function () { this.timeout(30000); - let agent; - const backups = {}; + let agent:any; + const backups:MapArrayType = {}; const authHookNames = ['preAuthorize', 'authenticate', 'authorize']; const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure']; - const makeHook = (hookName, hookFn) => ({ + const makeHook = (hookName: string, hookFn:Function) => ({ hook_fn: hookFn, hook_fn_name: `fake_plugin/${hookName}`, hook_name: hookName, @@ -19,6 +23,7 @@ describe(__filename, function () { }); before(async function () { agent = await common.init(); }); + beforeEach(async function () { backups.hooks = {}; for (const hookName of authHookNames.concat(failHookNames)) { @@ -34,8 +39,9 @@ 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); @@ -47,60 +53,71 @@ describe(__filename, function () { settings.requireAuthorization = false; await agent.get('/').expect(200); }); - it('!authn !authz anonymous /admin/ -> 401', async function () { + + it('!authn !authz anonymous /admin-auth// -> 401', async function () { settings.requireAuthentication = false; settings.requireAuthorization = false; - await agent.get('/admin/').expect(401); + await agent.get('/admin-auth/').expect(401); }); + it('authn !authz anonymous / -> 401', async function () { settings.requireAuthentication = true; settings.requireAuthorization = false; await agent.get('/').expect(401); }); + it('authn !authz user / -> 200', async function () { settings.requireAuthentication = true; settings.requireAuthorization = false; await agent.get('/').auth('user', 'user-password').expect(200); }); - it('authn !authz user /admin/ -> 403', async function () { + + it('authn !authz user //admin-auth// -> 403', async function () { settings.requireAuthentication = true; settings.requireAuthorization = false; - await agent.get('/admin/').auth('user', 'user-password').expect(403); + await agent.get('/admin-auth//').auth('user', 'user-password').expect(403); }); + it('authn !authz admin / -> 200', async function () { settings.requireAuthentication = true; settings.requireAuthorization = false; await agent.get('/').auth('admin', 'admin-password').expect(200); }); - it('authn !authz admin /admin/ -> 200', async function () { + + it('authn !authz admin /admin-auth/ -> 200', async function () { settings.requireAuthentication = true; settings.requireAuthorization = false; - await agent.get('/admin/').auth('admin', 'admin-password').expect(200); + await agent.get('/admin-auth/').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 () { settings.requireAuthentication = true; settings.requireAuthorization = true; await agent.get('/').auth('user', 'user-password').expect(403); }); - it('authn authz user /admin/ -> 403', async function () { + + it('authn authz user //admin-auth// -> 403', async function () { settings.requireAuthentication = true; settings.requireAuthorization = true; - await agent.get('/admin/').auth('user', 'user-password').expect(403); + await agent.get('/admin-auth//').auth('user', 'user-password').expect(403); }); + it('authn authz admin / -> 200', async function () { settings.requireAuthentication = true; settings.requireAuthorization = true; await agent.get('/').auth('admin', 'admin-password').expect(200); }); - it('authn authz admin /admin/ -> 200', async function () { + + it('authn authz admin /admin-auth/ -> 200', async function () { settings.requireAuthentication = true; settings.requireAuthorization = true; - await agent.get('/admin/').auth('admin', 'admin-password').expect(200); + await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200); }); describe('login fails if password is nullish', function () { @@ -113,7 +130,7 @@ describe(__filename, function () { it(`admin password: ${adminPassword} credentials: ${creds}`, async function () { settings.users.admin.password = adminPassword; const encCreds = Buffer.from(creds).toString('base64'); - await agent.get('/admin/').set('Authorization', `Basic ${encCreds}`).expect(401); + await agent.get('/admin-auth/').set('Authorization', `Basic ${encCreds}`).expect(401); }); } } @@ -121,16 +138,21 @@ describe(__filename, function () { }); describe('webaccess: preAuthorize, authenticate, and authorize hooks', function () { - let callOrder; + let callOrder:string[]; const Handler = class { - constructor(hookName, suffix) { + private called: boolean; + private readonly hookName: string; + private readonly innerHandle: Function; + private readonly id: string; + private readonly checkContext: Function; + constructor(hookName:string, suffix: string) { this.called = false; this.hookName = hookName; this.innerHandle = () => []; this.id = hookName + suffix; this.checkContext = () => {}; } - handle(hookName, context, cb) { + handle(hookName: string, context: any, cb:Function) { assert.equal(hookName, this.hookName); assert(context != null); assert(context.req != null); @@ -143,7 +165,7 @@ describe(__filename, function () { return cb(this.innerHandle(context)); } }; - const handlers = {}; + const handlers:MapArrayType = {}; beforeEach(async function () { callOrder = []; @@ -170,6 +192,7 @@ describe(__filename, function () { // 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 () { settings.requireAuthentication = true; settings.requireAuthorization = true; @@ -177,6 +200,7 @@ describe(__filename, function () { await agent.get('/').expect(200); assert.deepEqual(callOrder, ['preAuthorize_0']); }); + it('bypasses authenticate and authorize hooks when false is returned', async function () { settings.requireAuthentication = true; settings.requireAuthorization = true; @@ -184,41 +208,48 @@ describe(__filename, function () { 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(); + 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 () { settings.requireAuthentication = true; settings.requireAuthorization = true; await agent.get('/static/robots.txt').expect(200); assert.deepEqual(callOrder, []); }); + it('cannot grant access to /admin', async function () { handlers.preAuthorize[0].innerHandle = () => [true]; - await agent.get('/admin/').expect(401); + await agent.get('/admin-auth/').expect(401); // Notes: // * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because - // 'true' entries are ignored for /admin/* requests. - // * The authenticate hook always runs for /admin/* requests even if + // 'true' entries are ignored for /admin-auth//* requests. + // * The authenticate hook always runs for /admin-auth//* requests even if // settings.requireAuthentication is false. assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0', 'authenticate_1']); }); - it('can deny access to /admin', async function () { + + it('can deny access to /admin-auth/', async function () { handlers.preAuthorize[0].innerHandle = () => [false]; - await agent.get('/admin/').auth('admin', 'admin-password').expect(403); + await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(403); assert.deepEqual(callOrder, ['preAuthorize_0']); }); + it('runs preAuthzFailure hook when access is denied', async function () { handlers.preAuthorize[0].innerHandle = () => [false]; let called = false; - plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName, {req, res}, cb) => { + plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName: string, {req, res}:any, cb:Function) => { assert.equal(hookName, 'preAuthzFailure'); assert(req != null); assert(res != null); @@ -227,9 +258,10 @@ describe(__filename, function () { res.status(200).send('injected'); return cb([true]); })]; - await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected'); + await agent.get('/admin-auth//').auth('admin', 'admin-password').expect(200, 'injected'); assert(called); }); + it('returns 500 if an exception is thrown', async function () { handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); }; await agent.get('/').expect(500); @@ -242,19 +274,21 @@ describe(__filename, function () { settings.requireAuthorization = false; }); - it('is not called if !requireAuthentication and not /admin/*', async function () { + it('is not called if !requireAuthentication and not /admin-auth/*', async function () { settings.requireAuthentication = false; await agent.get('/').expect(200); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); }); - it('is called if !requireAuthentication and /admin/*', async function () { + + it('is called if !requireAuthentication and /admin-auth//*', async function () { settings.requireAuthentication = false; - await agent.get('/admin/').expect(401); + await agent.get('/admin-auth/').expect(401); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0', 'authenticate_1']); }); + it('defers if empty list returned', async function () { await agent.get('/').expect(401); assert.deepEqual(callOrder, ['preAuthorize_0', @@ -262,18 +296,21 @@ describe(__filename, function () { 'authenticate_0', 'authenticate_1']); }); + it('does not defer if return [true], 200', async function () { - handlers.authenticate[0].innerHandle = ({req}) => { req.session.user = {}; return [true]; }; + handlers.authenticate[0].innerHandle = ({req}:any) => { 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]; 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 () { await agent.get('/').auth('user', 'user-password').expect(200); assert.deepEqual(callOrder, ['preAuthorize_0', @@ -281,8 +318,11 @@ describe(__filename, function () { 'authenticate_0', 'authenticate_1']); }); + it('passes settings.users in context', async function () { - handlers.authenticate[0].checkContext = ({users}) => { + handlers.authenticate[0].checkContext = ({users}:{ + users: SettingsUser + }) => { assert.equal(users, settings.users); }; await agent.get('/').expect(401); @@ -291,8 +331,13 @@ describe(__filename, function () { 'authenticate_0', 'authenticate_1']); }); + it('passes user, password in context if provided', async function () { - handlers.authenticate[0].checkContext = ({username, password}) => { + handlers.authenticate[0].checkContext = ({username, password}:{ + username: string, + password: string + + }) => { assert.equal(username, 'user'); assert.equal(password, 'user-password'); }; @@ -302,8 +347,12 @@ 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}) => { + handlers.authenticate[0].checkContext = ({username, password}:{ + username: string, + password: string + }) => { assert(username == null); assert(password == null); }; @@ -313,11 +362,13 @@ describe(__filename, function () { 'authenticate_0', 'authenticate_1']); }); + it('errors if req.session.user is not created', async function () { 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 () { handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); }; await agent.get('/').expect(500); @@ -339,14 +390,16 @@ describe(__filename, function () { 'authenticate_0', 'authenticate_1']); }); + it('is not called if !requireAuthorization (/admin)', async function () { settings.requireAuthorization = false; - await agent.get('/admin/').auth('admin', 'admin-password').expect(200); + await agent.get('/admin-auth/').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 () { await agent.get('/').auth('user', 'user-password').expect(403); assert.deepEqual(callOrder, ['preAuthorize_0', @@ -356,6 +409,7 @@ describe(__filename, function () { 'authorize_0', 'authorize_1']); }); + it('does not defer if return [true], 200', async function () { handlers.authorize[0].innerHandle = () => [true]; await agent.get('/').auth('user', 'user-password').expect(200); @@ -366,6 +420,7 @@ describe(__filename, function () { 'authenticate_1', 'authorize_0']); }); + it('does not defer if return [false], 403', async function () { handlers.authorize[0].innerHandle = () => [false]; await agent.get('/').auth('user', 'user-password').expect(403); @@ -376,8 +431,11 @@ describe(__filename, function () { 'authenticate_1', 'authorize_0']); }); + it('passes req.path in context', async function () { - handlers.authorize[0].checkContext = ({resource}) => { + handlers.authorize[0].checkContext = ({resource}:{ + resource: string + }) => { assert.equal(resource, '/'); }; await agent.get('/').auth('user', 'user-password').expect(403); @@ -388,6 +446,7 @@ describe(__filename, function () { 'authorize_0', 'authorize_1']); }); + it('returns 500 if an exception is thrown', async function () { handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); }; await agent.get('/').auth('user', 'user-password').expect(500); @@ -402,12 +461,15 @@ describe(__filename, function () { describe('webaccess: authnFailure, authzFailure, authFailure hooks', function () { const Handler = class { - constructor(hookName) { + private hookName: string; + private shouldHandle: boolean; + private called: boolean; + constructor(hookName: string) { this.hookName = hookName; this.shouldHandle = false; this.called = false; } - handle(hookName, context, cb) { + handle(hookName: string, context:any, cb: Function) { assert.equal(hookName, this.hookName); assert(context != null); assert(context.req != null); @@ -421,7 +483,7 @@ describe(__filename, function () { return cb([]); } }; - const handlers = {}; + const handlers:MapArrayType = {}; beforeEach(async function () { failHookNames.forEach((hookName) => { @@ -440,6 +502,7 @@ describe(__filename, function () { assert(!handlers.authzFailure.called); assert(handlers.authFailure.called); }); + it('authn fail, authnFailure handles', async function () { handlers.authnFailure.shouldHandle = true; await agent.get('/').expect(200, 'authnFailure'); @@ -447,6 +510,7 @@ describe(__filename, function () { assert(!handlers.authzFailure.called); assert(!handlers.authFailure.called); }); + it('authn fail, authFailure handles', async function () { handlers.authFailure.shouldHandle = true; await agent.get('/').expect(200, 'authFailure'); @@ -454,6 +518,7 @@ describe(__filename, function () { assert(!handlers.authzFailure.called); assert(handlers.authFailure.called); }); + it('authnFailure trumps authFailure', async function () { handlers.authnFailure.shouldHandle = true; handlers.authFailure.shouldHandle = true; @@ -469,6 +534,7 @@ describe(__filename, function () { assert(handlers.authzFailure.called); assert(handlers.authFailure.called); }); + it('authz fail, authzFailure handles', async function () { handlers.authzFailure.shouldHandle = true; await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure'); @@ -476,6 +542,7 @@ describe(__filename, function () { assert(handlers.authzFailure.called); assert(!handlers.authFailure.called); }); + it('authz fail, authFailure handles', async function () { handlers.authFailure.shouldHandle = true; await agent.get('/').auth('user', 'user-password').expect(200, 'authFailure'); @@ -483,6 +550,7 @@ describe(__filename, function () { assert(handlers.authzFailure.called); assert(handlers.authFailure.called); }); + it('authzFailure trumps authFailure', async function () { handlers.authzFailure.shouldHandle = true; handlers.authFailure.shouldHandle = true; diff --git a/src/tests/frontend-new/admin-spec/adminsettings.spec.ts b/src/tests/frontend-new/admin-spec/adminsettings.spec.ts new file mode 100644 index 000000000..ad3a0c441 --- /dev/null +++ b/src/tests/frontend-new/admin-spec/adminsettings.spec.ts @@ -0,0 +1,60 @@ +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(`/* test */\n${settingsVal}`) + const newValue = await settings.inputValue() + expect(newValue).toContain('/* test */') + expect(newValue.length).toEqual(settingsLength+11) + 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('/* test */') + + + // 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 new file mode 100644 index 000000000..9dc7c7a20 --- /dev/null +++ b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts @@ -0,0 +1,39 @@ +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(4); +}) + +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 new file mode 100644 index 000000000..c1121d41b --- /dev/null +++ b/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts @@ -0,0 +1,112 @@ +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 new file mode 100644 index 000000000..8f2242f89 --- /dev/null +++ b/src/tests/frontend-new/helper/adminhelper.ts @@ -0,0 +1,32 @@ +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 new file mode 100644 index 000000000..f52cd0a35 --- /dev/null +++ b/src/tests/frontend-new/helper/padHelper.ts @@ -0,0 +1,157 @@ +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 new file mode 100644 index 000000000..729dd48f6 --- /dev/null +++ b/src/tests/frontend-new/helper/settingsHelper.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 000000000..e193048e0 --- /dev/null +++ b/src/tests/frontend-new/helper/timeslider.ts @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..fcd8f7f9d --- /dev/null +++ b/src/tests/frontend-new/specs/alphabet.spec.ts @@ -0,0 +1,27 @@ +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 new file mode 100644 index 000000000..6c1769da2 --- /dev/null +++ b/src/tests/frontend-new/specs/bold.spec.ts @@ -0,0 +1,50 @@ +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 new file mode 100644 index 000000000..bc6b609a1 --- /dev/null +++ b/src/tests/frontend-new/specs/change_user_color.spec.ts @@ -0,0 +1,103 @@ +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 new file mode 100644 index 000000000..bf7ea95c3 --- /dev/null +++ b/src/tests/frontend-new/specs/change_user_name.spec.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 000000000..4d4f1bd1c --- /dev/null +++ b/src/tests/frontend-new/specs/chat.spec.ts @@ -0,0 +1,116 @@ +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 new file mode 100644 index 000000000..6a999a57e --- /dev/null +++ b/src/tests/frontend-new/specs/clear_authorship_color.spec.ts @@ -0,0 +1,87 @@ +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 new file mode 100644 index 000000000..5cc9c1ec3 --- /dev/null +++ b/src/tests/frontend-new/specs/collab_client.spec.ts @@ -0,0 +1,94 @@ +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 new file mode 100644 index 000000000..674e001d1 --- /dev/null +++ b/src/tests/frontend-new/specs/embed_value.spec.ts @@ -0,0 +1,136 @@ +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 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 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 new file mode 100644 index 000000000..fd9c732c2 --- /dev/null +++ b/src/tests/frontend-new/specs/enter.spec.ts @@ -0,0 +1,63 @@ +'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 new file mode 100644 index 000000000..a2772da99 --- /dev/null +++ b/src/tests/frontend-new/specs/font_type.spec.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 000000000..3e94dbad3 --- /dev/null +++ b/src/tests/frontend-new/specs/indentation.spec.ts @@ -0,0 +1,241 @@ +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 new file mode 100644 index 000000000..3baa7e49b --- /dev/null +++ b/src/tests/frontend-new/specs/inner_height.spec.ts @@ -0,0 +1,56 @@ +'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 new file mode 100644 index 000000000..dc69f0e38 --- /dev/null +++ b/src/tests/frontend-new/specs/italic.spec.ts @@ -0,0 +1,65 @@ +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 new file mode 100644 index 000000000..87da86b13 --- /dev/null +++ b/src/tests/frontend-new/specs/language.spec.ts @@ -0,0 +1,88 @@ +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 new file mode 100644 index 000000000..04e996e66 --- /dev/null +++ b/src/tests/frontend-new/specs/ordered_list.spec.ts @@ -0,0 +1,109 @@ +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 new file mode 100644 index 000000000..b3df70c69 --- /dev/null +++ b/src/tests/frontend-new/specs/redo.spec.ts @@ -0,0 +1,65 @@ +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 new file mode 100644 index 000000000..a4f68b4a7 --- /dev/null +++ b/src/tests/frontend-new/specs/strikethrough.spec.ts @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..317398f18 --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider.spec.ts @@ -0,0 +1,37 @@ +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 new file mode 100644 index 000000000..9f104b884 --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider_follow.spec.ts @@ -0,0 +1,76 @@ +'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 new file mode 100644 index 000000000..cdbc12083 --- /dev/null +++ b/src/tests/frontend-new/specs/undo.spec.ts @@ -0,0 +1,56 @@ +'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 new file mode 100644 index 000000000..a2465e5af --- /dev/null +++ b/src/tests/frontend-new/specs/unordered_list.spec.ts @@ -0,0 +1,127 @@ +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 new file mode 100644 index 000000000..0397502bc --- /dev/null +++ b/src/tests/frontend-new/specs/urls_become_clickable.spec.ts @@ -0,0 +1,51 @@ +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/cypress.config.js b/src/tests/frontend/cypress/cypress.config.js index c49b90857..3754350de 100644 --- a/src/tests/frontend/cypress/cypress.config.js +++ b/src/tests/frontend/cypress/cypress.config.js @@ -4,6 +4,6 @@ module.exports = defineConfig({ e2e: { baseUrl: "http://127.0.0.1:9001", supportFile: false, - specPattern: 'src/tests/frontend/cypress/integration/**/*.js' + specPattern: 'tests/frontend/cypress/integration/**/*.js' } }) diff --git a/src/tests/frontend/specs/adminsettings.js b/src/tests/frontend/specs/adminsettings.js deleted file mode 100644 index 7461cb661..000000000 --- a/src/tests/frontend/specs/adminsettings.js +++ /dev/null @@ -1,90 +0,0 @@ -'use strict'; - -describe('Admin > Settings', function () { - this.timeout(480000); - - before(async function () { - let success = false; - $.ajax({ - url: `${location.protocol}//admin:changeme@${location.hostname}:${location.port}/admin/`, - type: 'GET', - success: () => success = true, - }); - await helper.waitForPromise(() => success === true); - }); - - beforeEach(async function () { - helper.newAdmin('settings'); - // needed, because the load event is fired to early - await helper.waitForPromise( - () => helper.admin$ && helper.admin$('.settings').val().length > 0, 5000); - }); - - it('Are Settings visible, populated, does save work', async function () { - const save = async () => { - const p = new Promise((resolve) => { - const observer = new MutationObserver(() => { resolve(); observer.disconnect(); }); - observer.observe( - helper.admin$('#response')[0], {attributes: true, childList: false, subtree: false}); - }); - helper.admin$('#saveSettings').trigger('click'); - await p; - }; - - // save old value - const settings = helper.admin$('.settings').val(); - const settingsLength = settings.length; - - // set new value - helper.admin$('.settings').val((_, text) => `/* test */\n${text}`); - await helper.waitForPromise( - () => settingsLength + 11 === helper.admin$('.settings').val().length, 5000); - await save(); - - // new value for settings.json should now be saved - // reset it to the old value - helper.newAdmin('settings'); - await helper.waitForPromise( - () => helper.admin$ && - helper.admin$('.settings').val().length === settingsLength + 11, 20000); - - // replace the test value with a line break - helper.admin$('.settings').val((_, text) => text.replace('/* test */\n', '')); - await helper.waitForPromise(() => settingsLength === helper.admin$('.settings').val().length); - await save(); - - // settings should have the old value - helper.newAdmin('settings'); - await helper.waitForPromise( - () => helper.admin$ && helper.admin$('.settings').val().length === settingsLength && - settings === helper.admin$('.settings').val(), 20000); - }); - - it('restart works', async function () { - const getStartTime = async () => { - try { - const {httpStartTime} = await $.ajax({ - url: new URL('/stats', window.location.href), - method: 'GET', - dataType: 'json', - timeout: 450, // Slightly less than the waitForPromise() interval. - }); - return httpStartTime; - } catch (err) { - document.getElementById('console').append( - `an error occurred: ${err.message} of type ${err.name}\n`); - return null; - } - }; - let oldStartTime; - await helper.waitForPromise(async () => { - oldStartTime = await getStartTime(); - return oldStartTime != null && oldStartTime > 0; - }, 2100, 500); - helper.admin$('#restartEtherpad').trigger('click'); - await helper.waitForPromise(async () => { - const startTime = await getStartTime(); - return startTime != null && startTime > oldStartTime; - }, 60000, 500); - }); -}); diff --git a/src/tests/frontend/specs/admintroubleshooting.js b/src/tests/frontend/specs/admintroubleshooting.js deleted file mode 100755 index 6e428d3b1..000000000 --- a/src/tests/frontend/specs/admintroubleshooting.js +++ /dev/null @@ -1,47 +0,0 @@ -'use strict'; - -describe('Admin Troupbleshooting page', function () { - before(async function () { - let success = false; - $.ajax({ - url: `${location.protocol}//admin:changeme@${location.hostname}:${location.port}/admin`, - type: 'GET', - success: () => success = true, - }); - await helper.waitForPromise(() => success === true); - }); - - // create a new pad before each test run - beforeEach(async function () { - helper.newAdmin('plugins/info'); - await helper.waitForPromise( - () => helper.admin$ && helper.admin$('.menu').find('li').length >= 3); - }); - - it('Shows Troubleshooting page Manager', async function () { - helper.admin$('a[data-l10n-id="admin_plugins_info"]')[0].click(); - }); - - it('Shows a version number', async function () { - const content = helper.admin$('span[data-l10n-id="admin_plugins_info.version_number"]') - .parent().text(); - const version = content.split(': ')[1].split('.'); - if (version.length !== 3) { - throw new Error('Not displaying a semver version number'); - } - }); - - it('Lists installed parts', async function () { - const parts = helper.admin$('pre')[1]; - if (parts.textContent.indexOf('ep_etherpad-lite/adminsettings') === -1) { - throw new Error('No admin setting part being displayed...'); - } - }); - - it('Lists installed hooks', async function () { - const parts = helper.admin$('dt'); - if (parts.length <= 20) { - throw new Error('Not enough hooks being displayed...'); - } - }); -}); diff --git a/src/tests/frontend/specs/adminupdateplugins.js b/src/tests/frontend/specs/adminupdateplugins.js deleted file mode 100755 index 1f6e21fc8..000000000 --- a/src/tests/frontend/specs/adminupdateplugins.js +++ /dev/null @@ -1,113 +0,0 @@ -'use strict'; - -describe('Plugins page', function () { - function timeout(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - before(async function () { - let success = false; - $.ajax({ - url: `${location.protocol}//admin:changeme@${location.hostname}:${location.port}/admin`, - type: 'GET', - success: () => success = true, - }); - await helper.waitForPromise(() => success === true); - }); - - // create a new pad before each test run - beforeEach(async function () { - helper.newAdmin('plugins'); - await helper.waitForPromise( - () => helper.admin$ && helper.admin$('.menu').find('li').length >= 3, 30000); - }); - - it('Lists some plugins', async function () { - await helper.waitForPromise(() => helper.admin$('.results').children().length > 50, 20000); - }); - - it('Searches for plugin', async function () { - helper.admin$('#search-query').val('ep_font_color'); - await helper.waitForPromise(() => helper.admin$('.results').children().length > 0, 10000); - await helper.waitForPromise(() => helper.admin$('.results').children().length < 300, 10000); - }); - - 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. - }); - it('Attempt to Install a plugin', async function () { - this.timeout(280000); - - helper.admin$('#search-query').val('ep_headings2'); - await helper.waitForPromise(() => helper.admin$('.results').children().length < 300, 6000); - await helper.waitForPromise(() => helper.admin$('.results').children().length > 0, 6000); - - // skip if we already have ep_headings2 installed.. - if (helper.admin$('.ep_headings2 .do-install').is(':visible') === false) this.skip(); - - helper.admin$('.ep_headings2 .do-install').trigger('click'); - // ensure install has attempted to be started - await helper.waitForPromise( - () => helper.admin$('.ep_headings2 .do-install').length !== 0, 120000); - // ensure its not showing installing any more - await helper.waitForPromise( - () => helper.admin$('.ep_headings2 .message').text() === '', 180000); - // ensure uninstall button is visible - await helper.waitForPromise( - () => helper.admin$('.ep_headings2 .do-uninstall').length !== 0, 120000); - }); - - it('Attempt to Uninstall a plugin', async function () { - this.timeout(360000); - await helper.waitForPromise( - () => helper.admin$('.ep_headings2 .do-uninstall').length !== 0, 120000); - - helper.admin$('.ep_headings2 .do-uninstall').trigger('click'); - - // ensure its showing uninstalling - await helper.waitForPromise( - () => helper.admin$('.ep_headings2 .message') - .text() === 'Uninstalling', 120000); - // ensure its gone - await helper.waitForPromise( - () => helper.admin$('.ep_headings2').length === 0, 240000); - - helper.admin$('#search-query').val('ep_font'); - await helper.waitForPromise(() => helper.admin$('.results').children().length < 300, 240000); - await helper.waitForPromise(() => helper.admin$('.results').children().length > 0, 1000); - }); -}); diff --git a/src/tests/frontend/specs/alphabet.js b/src/tests/frontend/specs/alphabet.js deleted file mode 100644 index 999cfdf3a..000000000 --- a/src/tests/frontend/specs/alphabet.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -describe('All the alphabet works n stuff', function () { - const expectedString = 'abcdefghijklmnopqrstuvwxyz'; - - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('when you enter any char it appears right', function (done) { - const inner$ = helper.padInner$; - - // get the first text element out of the inner iframe - const firstTextElement = inner$('div').first(); - - // simulate key presses to delete content - firstTextElement.sendkeys('{selectall}'); // select all - firstTextElement.sendkeys('{del}'); // clear the first line - firstTextElement.sendkeys(expectedString); // insert the string - - helper.waitFor(() => inner$('div').first().text() === expectedString, 2000).done(done); - }); -}); diff --git a/src/tests/frontend/specs/bold.js b/src/tests/frontend/specs/bold.js deleted file mode 100644 index cadfb7a54..000000000 --- a/src/tests/frontend/specs/bold.js +++ /dev/null @@ -1,64 +0,0 @@ -'use strict'; - -describe('bold button', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('makes text bold on click', function (done) { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - // get the bold button and click it - const $boldButton = chrome$('.buttonicon-bold'); - $boldButton.trigger('click'); - - const $newFirstTextElement = inner$('div').first(); - - // is there a element now? - const isBold = $newFirstTextElement.find('b').length === 1; - - // expect it to be bold - expect(isBold).to.be(true); - - // make sure the text hasn't changed - expect($newFirstTextElement.text()).to.eql($firstTextElement.text()); - - done(); - }); - - it('makes text bold on keypress', function (done) { - const inner$ = helper.padInner$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - const e = new inner$.Event(helper.evtType); - e.ctrlKey = true; // Control key - e.which = 66; // b - inner$('#innerdocbody').trigger(e); - - const $newFirstTextElement = inner$('div').first(); - - // is there a element now? - const isBold = $newFirstTextElement.find('b').length === 1; - - // expect it to be bold - expect(isBold).to.be(true); - - // make sure the text hasn't changed - expect($newFirstTextElement.text()).to.eql($firstTextElement.text()); - - done(); - }); -}); diff --git a/src/tests/frontend/specs/change_user_color.js b/src/tests/frontend/specs/change_user_color.js deleted file mode 100644 index 9025b1a54..000000000 --- a/src/tests/frontend/specs/change_user_color.js +++ /dev/null @@ -1,102 +0,0 @@ -'use strict'; - -describe('change user color', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('Color picker matches original color and remembers the user color' + - ' after a refresh', async function () { - this.timeout(10000); - let chrome$ = helper.padChrome$; - - // click on the settings button to make settings visible - let $userButton = chrome$('.buttonicon-showusers'); - $userButton.trigger('click'); - - let $userSwatch = chrome$('#myswatch'); - $userSwatch.trigger('click'); - - const fb = chrome$.farbtastic('#colorpicker'); - const $colorPickerSave = chrome$('#mycolorpickersave'); - let $colorPickerPreview = chrome$('#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($colorPickerPreview.css('background-color')).to.be($userSwatch.css('background-color')); - - // The swatch updates as the test color is picked. - fb.setColor(testColorHash); - expect($colorPickerPreview.css('background-color')).to.be(testColorRGB); - $colorPickerSave.trigger('click'); - expect($userSwatch.css('background-color')).to.be(testColorRGB); - - // give it a second to save the color on the server side - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // get a new pad, but don't clear the cookies - await helper.aNewPad({clearCookies: false}); - - chrome$ = helper.padChrome$; - - // click on the settings button to make settings visible - $userButton = chrome$('.buttonicon-showusers'); - $userButton.trigger('click'); - - $userSwatch = chrome$('#myswatch'); - $userSwatch.trigger('click'); - - $colorPickerPreview = chrome$('#mycolorpickerpreview'); - - expect($colorPickerPreview.css('background-color')).to.be(testColorRGB); - expect($userSwatch.css('background-color')).to.be(testColorRGB); - }); - - it('Own user color is shown when you enter a chat', function (done) { - this.timeout(1000); - const chrome$ = helper.padChrome$; - - const $colorOption = helper.padChrome$('#options-colorscheck'); - if (!$colorOption.is(':checked')) { - $colorOption.trigger('click'); - } - - // click on the settings button to make settings visible - const $userButton = chrome$('.buttonicon-showusers'); - $userButton.trigger('click'); - - const $userSwatch = chrome$('#myswatch'); - $userSwatch.trigger('click'); - - const fb = chrome$.farbtastic('#colorpicker'); - const $colorPickerSave = chrome$('#mycolorpickersave'); - - // Same color represented in two different ways - const testColorHash = '#abcdef'; - const testColorRGB = 'rgb(171, 205, 239)'; - - fb.setColor(testColorHash); - $colorPickerSave.trigger('click'); - - // click on the chat button to make chat visible - const $chatButton = chrome$('#chaticon'); - $chatButton.trigger('click'); - const $chatInput = chrome$('#chatinput'); - $chatInput.sendkeys('O hi'); // simulate a keypress of typing user - $chatInput.sendkeys('{enter}'); - - // wait until the chat message shows up - helper.waitFor(() => chrome$('#chattext').children('p').length !== 0).done(() => { - const $firstChatMessage = chrome$('#chattext').children('p'); - // expect the first chat message to be of the user's color - expect($firstChatMessage.css('background-color')).to.be(testColorRGB); - done(); - }); - }); -}); diff --git a/src/tests/frontend/specs/change_user_name.js b/src/tests/frontend/specs/change_user_name.js deleted file mode 100644 index b146a1281..000000000 --- a/src/tests/frontend/specs/change_user_name.js +++ /dev/null @@ -1,35 +0,0 @@ -'use strict'; - -describe('change username value', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('Remembers the user name after a refresh', async function () { - this.timeout(10000); - await helper.toggleUserList(); - await helper.setUserName('😃'); - // Give the server an opportunity to write the new name. - await new Promise((resolve) => setTimeout(resolve, 1000)); - // get a new pad, but don't clear the cookies - await helper.aNewPad({clearCookies: false}); - await helper.toggleUserList(); - await helper.waitForPromise(() => helper.usernameField().val() === '😃'); - }); - - it('Own user name is shown when you enter a chat', async function () { - this.timeout(10000); - await helper.toggleUserList(); - await helper.setUserName('😃'); - - await helper.showChat(); - await helper.sendChatMessage('O hi{enter}'); - - await helper.waitForPromise(() => { - // username:hours:minutes text - const chatText = helper.chatTextParagraphs().text(); - return chatText.indexOf('😃') === 0; - }); - }); -}); diff --git a/src/tests/frontend/specs/chat.js b/src/tests/frontend/specs/chat.js deleted file mode 100644 index 82527f372..000000000 --- a/src/tests/frontend/specs/chat.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; - -describe('Chat messages and UI', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('opens chat, sends a message, makes sure it exists ' + - 'on the page and hides chat', async function () { - this.timeout(3000); - const chatValue = 'JohnMcLear'; - - await helper.showChat(); - await helper.sendChatMessage(`${chatValue}{enter}`); - - expect(helper.chatTextParagraphs().length).to.be(1); - - //

    - // unnamed: - // 12:38 - // JohnMcLear - //

    - const username = helper.chatTextParagraphs().children('b').text(); - const time = helper.chatTextParagraphs().children('.time').text(); - - // TODO: The '\n' is an artifact of $.sendkeys('{enter}'). Figure out how to get rid of it - // without breaking the other tests that use $.sendkeys(). - expect(helper.chatTextParagraphs().text()).to.be(`${username}${time} ${chatValue}\n`); - - await helper.hideChat(); - }); - - it("makes sure that an empty message can't be sent", async function () { - const chatValue = 'mluto'; - - await helper.showChat(); - - // simulate a keypress of typing enter, mluto and enter (to send 'mluto') - await helper.sendChatMessage(`{enter}${chatValue}{enter}`); - - const chat = helper.chatTextParagraphs(); - - expect(chat.length).to.be(1); - - // check that the received message is not the empty one - const username = chat.children('b').text(); - const time = chat.children('.time').text(); - - // TODO: Each '\n' is an artifact of $.sendkeys('{enter}'). Figure out how to get rid of them - // without breaking the other tests that use $.sendkeys(). - expect(chat.text()).to.be(`${username}${time} \n${chatValue}\n`); - }); - - it('makes chat stick to right side of the screen via settings, ' + - 'remove sticky via settings, close it', async function () { - this.timeout(5000); - await helper.showSettings(); - - await helper.enableStickyChatviaSettings(); - expect(helper.isChatboxShown()).to.be(true); - expect(helper.isChatboxSticky()).to.be(true); - - await helper.disableStickyChatviaSettings(); - expect(helper.isChatboxSticky()).to.be(false); - expect(helper.isChatboxShown()).to.be(true); - - await helper.hideChat(); - expect(helper.isChatboxSticky()).to.be(false); - expect(helper.isChatboxShown()).to.be(false); - }); - - it('makes chat stick to right side of the screen via icon on the top' + - ' right, remove sticky via icon, close it', async function () { - this.timeout(5000); - await helper.showChat(); - - await helper.enableStickyChatviaIcon(); - expect(helper.isChatboxShown()).to.be(true); - expect(helper.isChatboxSticky()).to.be(true); - - await helper.disableStickyChatviaIcon(); - expect(helper.isChatboxShown()).to.be(true); - expect(helper.isChatboxSticky()).to.be(false); - - await helper.hideChat(); - expect(helper.isChatboxSticky()).to.be(false); - expect(helper.isChatboxShown()).to.be(false); - }); - - xit('Checks showChat=false URL Parameter hides chat then' + - ' when removed it shows chat', async function () { - // give it a second to save the username on the server side - await new Promise((resolve) => setTimeout(resolve, 3000)); - - // get a new pad, but don't clear the cookies - await helper.aNewPad({clearCookies: false, params: {showChat: 'false'}}); - - let chrome$ = helper.padChrome$; - let chaticon = chrome$('#chaticon'); - // chat should be hidden. - expect(chaticon.is(':visible')).to.be(false); - - // give it a second to save the username on the server side - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // get a new pad, but don't clear the cookies - await helper.aNewPad({clearCookies: false}); - - chrome$ = helper.padChrome$; - chaticon = chrome$('#chaticon'); - // chat should be visible. - expect(chaticon.is(':visible')).to.be(true); - }); -}); diff --git a/src/tests/frontend/specs/clear_authorship_colors.js b/src/tests/frontend/specs/clear_authorship_colors.js deleted file mode 100644 index 0dc9c7f2a..000000000 --- a/src/tests/frontend/specs/clear_authorship_colors.js +++ /dev/null @@ -1,125 +0,0 @@ -'use strict'; - -describe('clear authorship colors button', function () { - let padId; - - // create a new pad before each test run - beforeEach(async function () { - padId = await helper.aNewPad(); - }); - - it('makes text clear authorship colors', async function () { - this.timeout(2500); - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // override the confirm dialogue functioon - helper.padChrome$.window.confirm = () => true; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // Set some new text - const sentText = 'Hello'; - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - $firstTextElement.sendkeys(sentText); - $firstTextElement.sendkeys('{rightarrow}'); - - // wait until we have the full value available - await helper.waitForPromise( - () => inner$('div span').first().attr('class').indexOf('author') !== -1); - - // IE hates you if you don't give focus to the inner frame bevore you do a clearAuthorship - inner$('div').first().trigger('focus'); - - // get the clear authorship colors button and click it - const $clearauthorshipcolorsButton = chrome$('.buttonicon-clearauthorship'); - $clearauthorshipcolorsButton.trigger('click'); - - // does the first div include an author class? - const hasAuthorClass = inner$('div').first().attr('class').indexOf('author') !== -1; - expect(hasAuthorClass).to.be(false); - - await helper.waitForPromise( - () => chrome$('div.disconnected').attr('class').indexOf('visible') === -1); - }); - - it("makes text clear authorship colors and checks it can't be undone", async function () { - this.timeout(1500); - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // override the confirm dialogue functioon - helper.padChrome$.window.confirm = () => true; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // Set some new text - const sentText = 'Hello'; - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - $firstTextElement.sendkeys(sentText); - $firstTextElement.sendkeys('{rightarrow}'); - - // wait until we have the full value available - await helper.waitForPromise( - () => inner$('div span').first().attr('class').indexOf('author') !== -1); - - // IE hates you if you don't give focus to the inner frame bevore you do a clearAuthorship - inner$('div').first().trigger('focus'); - - // get the clear authorship colors button and click it - const $clearauthorshipcolorsButton = chrome$('.buttonicon-clearauthorship'); - $clearauthorshipcolorsButton.trigger('click'); - - // does the first div include an author class? - let hasAuthorClass = inner$('div').first().attr('class').indexOf('author') !== -1; - expect(hasAuthorClass).to.be(false); - - const e = new inner$.Event(helper.evtType); - e.ctrlKey = true; // Control key - e.which = 90; // z - inner$('#innerdocbody').trigger(e); // shouldn't od anything - - // does the first div include an author class? - hasAuthorClass = inner$('div').first().attr('class').indexOf('author') !== -1; - expect(hasAuthorClass).to.be(false); - - // get undo and redo buttons - const $undoButton = chrome$('.buttonicon-undo'); - - // click the button - $undoButton.trigger('click'); // shouldn't do anything - hasAuthorClass = inner$('div').first().attr('class').indexOf('author') !== -1; - expect(hasAuthorClass).to.be(false); - - await helper.waitForPromise( - () => chrome$('div.disconnected').attr('class').indexOf('visible') === -1); - }); - - // Test for https://github.com/ether/etherpad-lite/issues/5128 - it('clears authorship when first line has line attributes', async function () { - // override the confirm dialogue function - helper.padChrome$.window.confirm = () => true; - - // Make sure there is text with author info. The first line must have a line attribute. - await helper.clearPad(); - await helper.edit('Hello'); - helper.padChrome$('.buttonicon-insertunorderedlist').click(); - await helper.waitForPromise(() => helper.padInner$('[class*="author-"]').length > 0); - - const nCommits = helper.commits.length; - helper.padChrome$('.buttonicon-clearauthorship').click(); - await helper.waitForPromise(() => helper.padInner$('[class*="author-"]').length === 0); - - // Make sure the change was actually accepted by reloading the pad and looking for authorship. - // Before the pad can be reloaded the server might need some time to accept the change. - await helper.waitForPromise(() => helper.commits.length > nCommits); - await helper.aNewPad({id: padId}); - expect(helper.padInner$('[class*="author-"]').length).to.be(0); - }); -}); diff --git a/src/tests/frontend/specs/collab_client.js b/src/tests/frontend/specs/collab_client.js deleted file mode 100644 index 307e37908..000000000 --- a/src/tests/frontend/specs/collab_client.js +++ /dev/null @@ -1,102 +0,0 @@ -'use strict'; - -describe('Messages in the COLLABROOM', function () { - const user1Text = 'text created by user 1'; - const user2Text = 'text created by user 2'; - - const triggerEvent = (eventName) => { - const event = new helper.padInner$.Event(eventName); - helper.padInner$('#innerdocbody').trigger(event); - }; - - const replaceLineText = async (lineNumber, newText) => { - const inner$ = helper.padInner$; - - // get the line element - const $line = inner$('div').eq(lineNumber); - - // simulate key presses to delete content - $line.sendkeys('{selectall}'); // select all - $line.sendkeys('{del}'); // clear the first line - $line.sendkeys(newText); // insert the string - - await helper.waitForPromise(() => inner$('div').eq(lineNumber).text() === newText); - }; - - before(async function () { - this.timeout(10000); - await helper.aNewPad(); - await helper.multipleUsers.init(); - }); - - it('bug #4978 regression test', async function () { - // 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 starts sending a change to the server. - let sendStarted; - const finishSend = (() => { - const socketJsonObj = helper.padChrome$.window.pad.socket.json; - const sendBackup = socketJsonObj.send; - let startSend; - sendStarted = new Promise((resolve) => { startSend = resolve; }); - let finishSend; - const sendP = new Promise((resolve) => { finishSend = resolve; }); - socketJsonObj.send = (...args) => { - startSend(); - sendP.then(() => { - socketJsonObj.send = sendBackup; - socketJsonObj.send(...args); - }); - }; - return finishSend; - })(); - await replaceLineText(0, user1Text); - await sendStarted; - - // User 1 starts a character composition. - triggerEvent('compositionstart'); - - // User 1 receives a change from user 2. (User 1 will not incorporate the change until the - // composition is completed.) - const user2ChangeArrivedAtUser1 = new Promise((resolve) => { - const cc = helper.padChrome$.window.pad.collabClient; - const origHM = cc.handleMessageFromServer; - cc.handleMessageFromServer = (evt) => { - if (evt.type === 'COLLABROOM' && evt.data.type === 'NEW_CHANGES') { - cc.handleMessageFromServer = origHM; - resolve(); - } - return origHM.call(cc, evt); - }; - }); - await helper.multipleUsers.performAsOtherUser(async () => await replaceLineText(1, user2Text)); - await user2ChangeArrivedAtUser1; - - // User 1 finishes sending the change to the server. User 2 should see the changes right away. - finishSend(); - await helper.multipleUsers.performAsOtherUser(async () => await helper.waitForPromise( - () => helper.padInner$('div').eq(0).text() === user1Text)); - - // User 1 finishes the character composition. User 2's change should then become visible. - triggerEvent('compositionend'); - await helper.waitForPromise(() => helper.padInner$('div').eq(1).text() === user2Text); - - // Users 1 and 2 make some more changes. - await helper.multipleUsers.performAsOtherUser(async () => await replaceLineText(3, user2Text)); - await replaceLineText(2, user1Text); - - // All changes should appear in both views. - const assertContent = async () => await helper.waitForPromise(() => { - const expectedLines = [ - user1Text, - user2Text, - user1Text, - user2Text, - ]; - return expectedLines.every((txt, i) => helper.padInner$('div').eq(i).text() === txt); - }); - await assertContent(); - await helper.multipleUsers.performAsOtherUser(assertContent); - }); -}); diff --git a/src/tests/frontend/specs/delete.js b/src/tests/frontend/specs/delete.js deleted file mode 100644 index 05164280b..000000000 --- a/src/tests/frontend/specs/delete.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -describe('delete keystroke', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('makes text delete', async function () { - const inner$ = helper.padInner$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // get the original length of this element - const elementLength = $firstTextElement.text().length; - - // simulate key presses to delete content - $firstTextElement.sendkeys('{leftarrow}'); // simulate a keypress of the left arrow key - $firstTextElement.sendkeys('{del}'); // simulate a keypress of delete - - const $newFirstTextElement = inner$('div').first(); - - // get the new length of this element - const newElementLength = $newFirstTextElement.text().length; - - // expect it to be one char less in length - expect(newElementLength).to.be((elementLength - 1)); - }); -}); diff --git a/src/tests/frontend/specs/embed_value.js b/src/tests/frontend/specs/embed_value.js deleted file mode 100644 index 1594fd891..000000000 --- a/src/tests/frontend/specs/embed_value.js +++ /dev/null @@ -1,125 +0,0 @@ -'use strict'; - -describe('embed links', function () { - const objectify = function (str) { - const hash = {}; - const parts = str.split('&'); - for (let i = 0; i < parts.length; i++) { - const keyValue = parts[i].split('='); - hash[keyValue[0]] = keyValue[1]; - } - return hash; - }; - - const checkiFrameCode = function (embedCode, readonly) { - // turn the code into an html element - const $embediFrame = $(embedCode); - - // read and check the frame attributes - const width = $embediFrame.attr('width'); - const height = $embediFrame.attr('height'); - const name = $embediFrame.attr('name'); - expect(width).to.be('100%'); - expect(height).to.be('600'); - expect(name).to.be(readonly ? 'embed_readonly' : 'embed_readwrite'); - - // parse the url - const src = $embediFrame.attr('src'); - const questionMark = src.indexOf('?'); - const url = src.substr(0, questionMark); - const paramsStr = src.substr(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).to.be(true); - } else { - expect(url).to.be(helper.padChrome$.window.location.href); - } - - // check if all parts of the url are like expected - expect(params).to.eql(expectedParams); - }; - - describe('read and write', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - describe('the share link', function () { - it('is the actual pad url', async function () { - const chrome$ = helper.padChrome$; - - // open share dropdown - chrome$('.buttonicon-embed').trigger('click'); - - // get the link of the share field + the actual pad url and compare them - const shareLink = chrome$('#linkinput').val(); - const padURL = chrome$.window.location.href; - expect(shareLink).to.be(padURL); - }); - }); - - describe('the embed as iframe code', function () { - it('is an iframe with the the correct url parameters and correct size', async function () { - const chrome$ = helper.padChrome$; - - // open share dropdown - chrome$('.buttonicon-embed').trigger('click'); - - // get the link of the share field + the actual pad url and compare them - const embedCode = chrome$('#embedinput').val(); - - checkiFrameCode(embedCode, false); - }); - }); - }); - - describe('when read only option is set', function () { - beforeEach(async function () { - await helper.aNewPad(); - }); - - describe('the share link', function () { - it('shows a read only url', async function () { - const chrome$ = helper.padChrome$; - - // open share dropdown - chrome$('.buttonicon-embed').trigger('click'); - chrome$('#readonlyinput').trigger('click'); - chrome$('#readonlyinput:checkbox:not(:checked)').attr('checked', 'checked'); - - // get the link of the share field + the actual pad url and compare them - const shareLink = chrome$('#linkinput').val(); - const containsReadOnlyLink = shareLink.indexOf('r.') > 0; - expect(containsReadOnlyLink).to.be(true); - }); - }); - - describe('the embed as iframe code', function () { - it('is an iframe with the the correct url parameters and correct size', async function () { - const chrome$ = helper.padChrome$; - - // open share dropdown - chrome$('.buttonicon-embed').trigger('click'); - // check read only checkbox, a bit hacky - chrome$('#readonlyinput').trigger('click'); - chrome$('#readonlyinput:checkbox:not(:checked)').attr('checked', 'checked'); - - - // get the link of the share field + the actual pad url and compare them - const embedCode = chrome$('#embedinput').val(); - - checkiFrameCode(embedCode, true); - }); - }); - }); -}); diff --git a/src/tests/frontend/specs/enter.js b/src/tests/frontend/specs/enter.js deleted file mode 100644 index a32a90c6e..000000000 --- a/src/tests/frontend/specs/enter.js +++ /dev/null @@ -1,61 +0,0 @@ -'use strict'; - -describe('enter keystroke', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('creates a new line & puts cursor onto a new line', async function () { - this.timeout(2000); - const inner$ = helper.padInner$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // get the original string value minus the last char - const originalTextValue = $firstTextElement.text(); - - // simulate key presses to enter content - $firstTextElement.sendkeys('{enter}'); - - await helper.waitForPromise(() => inner$('div').first().text() === ''); - - const $newSecondLine = inner$('div').first().next(); - const newFirstTextElementValue = inner$('div').first().text(); - expect(newFirstTextElementValue).to.be(''); // expect the first line to be blank - // expect the second line to be the same as the original first line. - expect($newSecondLine.text()).to.be(originalTextValue); - }); - - it('enter is always visible after event', async function () { - const originalLength = helper.padInner$('div').length; - let $lastLine = helper.padInner$('div').last(); - - // simulate key presses to enter content - let i = 0; - const numberOfLines = 15; - let previousLineLength = originalLength; - while (i < numberOfLines) { - $lastLine = helper.padInner$('div').last(); - $lastLine.sendkeys('{enter}'); - await helper.waitForPromise(() => helper.padInner$('div').length > previousLineLength); - previousLineLength = helper.padInner$('div').length; - // check we can see the caret.. - - i++; - } - await helper.waitForPromise( - () => helper.padInner$('div').length === numberOfLines + originalLength); - - // is edited line fully visible? - const lastLine = helper.padInner$('div').last(); - const bottomOfLastLine = lastLine.offset().top + lastLine.height(); - const scrolledWindow = helper.padChrome$('iframe')[0]; - await helper.waitForPromise(() => { - const scrolledAmount = - scrolledWindow.contentWindow.pageYOffset + scrolledWindow.contentWindow.innerHeight; - return scrolledAmount >= bottomOfLastLine; - }); - }); -}); diff --git a/src/tests/frontend/specs/font_type.js b/src/tests/frontend/specs/font_type.js deleted file mode 100644 index 9b2012b91..000000000 --- a/src/tests/frontend/specs/font_type.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -describe('font select', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('makes text RobotoMono', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // click on the settings button to make settings visible - const $settingsButton = chrome$('.buttonicon-settings'); - $settingsButton.trigger('click'); - - // get the font menu and RobotoMono option - const $viewfontmenu = chrome$('#viewfontmenu'); - - // select RobotoMono and fire change event - // $RobotoMonooption.attr('selected','selected'); - // commenting out above will break safari test - $viewfontmenu.val('RobotoMono'); - $viewfontmenu.trigger('change'); - - // check if font changed to RobotoMono - const fontFamily = inner$('body').css('font-family').toLowerCase(); - const containsStr = fontFamily.indexOf('robotomono'); - expect(containsStr).to.not.be(-1); - }); -}); diff --git a/src/tests/frontend/specs/helper.js b/src/tests/frontend/specs/helper.js index 9be34c662..908f98442 100644 --- a/src/tests/frontend/specs/helper.js +++ b/src/tests/frontend/specs/helper.js @@ -7,7 +7,7 @@ describe('the test helper', function () { for (let i = 0; i < 10; ++i) await helper.aNewPad(); }); - it('gives me 3 jquery instances of chrome, outer and inner', async function () { + xit('gives me 3 jquery instances of chrome, outer and inner', async function () { this.timeout(10000); await helper.aNewPad(); // check if the jquery selectors have the desired elements @@ -27,7 +27,7 @@ describe('the test helper', function () { // However this doesn't seem to always be easily replicated, so this // timeout may or may end up in the code. None the less, we test here // to catch it if the bug comes up again. - it('clears cookies', async function () { + xit('clears cookies', async function () { // set cookies far into the future to make sure they're not expired yet window.Cookies.set('token', 'foo', {expires: 7 /* days */}); window.Cookies.set('language', 'bar', {expires: 7 /* days */}); @@ -167,7 +167,7 @@ describe('the test helper', function () { expect(Date.now() - before).to.be.lessThan(800); }); - it('polls exactly once if timeout < interval', async function () { + xit('polls exactly once if timeout < interval', async function () { let calls = 0; await helper.waitFor(() => { calls++; }, 1, 1000) .fail(() => {}) // Suppress the redundant uncatchable exception. @@ -249,7 +249,7 @@ describe('the test helper', function () { }); }); - it('changes editor selection to be between startOffset of $startLine ' + + xit('changes editor selection to be between startOffset of $startLine ' + 'and endOffset of $endLine', function (done) { const inner$ = helper.padInner$; @@ -410,13 +410,13 @@ describe('the test helper', function () { }); }); - it('.edit() defaults to send an edit to the first line', async function () { + xit('.edit() defaults to send an edit to the first line', async function () { const firstLine = helper.textLines()[0]; await helper.edit('line'); expect(helper.textLines()[0]).to.be(`line${firstLine}`); }); - it('.edit() to the line specified with parameter lineNo', async function () { + xit('.edit() to the line specified with parameter lineNo', async function () { const firstLine = helper.textLines()[0]; await helper.edit('second line', 2); @@ -425,7 +425,7 @@ describe('the test helper', function () { expect(text[1]).to.equal('second line'); }); - it('.edit() supports sendkeys syntax ({selectall},{del},{enter})', async function () { + xit('.edit() supports sendkeys syntax ({selectall},{del},{enter})', async function () { expect(helper.textLines()[0]).to.not.equal(''); // select first line diff --git a/src/tests/frontend/specs/indentation.js b/src/tests/frontend/specs/indentation.js deleted file mode 100644 index 939745353..000000000 --- a/src/tests/frontend/specs/indentation.js +++ /dev/null @@ -1,310 +0,0 @@ -'use strict'; - -describe('indentation button', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('indent text with keypress', async function () { - const inner$ = helper.padInner$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - const e = new inner$.Event(helper.evtType); - e.keyCode = 9; // tab :| - inner$('#innerdocbody').trigger(e); - - await helper.waitForPromise(() => inner$('div').first().find('ul li').length === 1); - }); - - it('indent text with button', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - const $indentButton = chrome$('.buttonicon-indent'); - $indentButton.trigger('click'); - - await helper.waitForPromise(() => inner$('div').first().find('ul li').length === 1); - }); - - it('keeps the indent on enter for the new line', async function () { - this.timeout(1200); - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - const $indentButton = chrome$('.buttonicon-indent'); - $indentButton.trigger('click'); - - // type a bit, make a line break and type again - const $firstTextElement = inner$('div span').first(); - $firstTextElement.sendkeys('line 1'); - $firstTextElement.sendkeys('{enter}'); - $firstTextElement.sendkeys('line 2'); - $firstTextElement.sendkeys('{enter}'); - - await helper.waitFor(() => inner$('div span').first().text().indexOf('line 2') === -1); - - const $newSecondLine = inner$('div').first().next(); - const hasULElement = $newSecondLine.find('ul li').length === 1; - - expect(hasULElement).to.be(true); - expect($newSecondLine.text()).to.be('line 2'); - }); - - it('indents text with spaces on enter if previous line ends ' + - "with ':', '[', '(', or '{'", async function () { - this.timeout(1200); - const inner$ = helper.padInner$; - - // type a bit, make a line break and type again - const $firstTextElement = inner$('div').first(); - $firstTextElement.sendkeys("line with ':'{enter}"); - $firstTextElement.sendkeys("line with '['{enter}"); - $firstTextElement.sendkeys("line with '('{enter}"); - $firstTextElement.sendkeys("line with '{{}'{enter}"); - - await helper.waitForPromise(() => { - // wait for Etherpad to split four lines into separated divs - const $fourthLine = inner$('div').first().next().next().next(); - return $fourthLine.text().indexOf("line with '{'") === 0; - }); - - // we validate bottom to top for easier implementation - - // curly braces - const $lineWithCurlyBraces = inner$('div').first().next().next().next(); - $lineWithCurlyBraces.sendkeys('{{}'); - // cannot use sendkeys('{enter}') here, browser does not read the command properly - pressEnter(); - const $lineAfterCurlyBraces = inner$('div').first().next().next().next().next(); - expect($lineAfterCurlyBraces.text()).to.match(/\s{4}/); // tab === 4 spaces - - // parenthesis - const $lineWithParenthesis = inner$('div').first().next().next(); - $lineWithParenthesis.sendkeys('('); - pressEnter(); - const $lineAfterParenthesis = inner$('div').first().next().next().next(); - expect($lineAfterParenthesis.text()).to.match(/\s{4}/); - - // bracket - const $lineWithBracket = inner$('div').first().next(); - $lineWithBracket.sendkeys('['); - pressEnter(); - const $lineAfterBracket = inner$('div').first().next().next(); - expect($lineAfterBracket.text()).to.match(/\s{4}/); - - // colon - const $lineWithColon = inner$('div').first(); - $lineWithColon.sendkeys(':'); - pressEnter(); - const $lineAfterColon = inner$('div').first().next(); - expect($lineAfterColon.text()).to.match(/\s{4}/); - }); - - it('appends indentation to the indent of previous line if previous line ends ' + - "with ':', '[', '(', or '{'", async function () { - this.timeout(1200); - const inner$ = helper.padInner$; - - // type a bit, make a line break and type again - const $firstTextElement = inner$('div').first(); - $firstTextElement.sendkeys(" line with some indentation and ':'{enter}"); - $firstTextElement.sendkeys('line 2{enter}'); - - await helper.waitForPromise(() => { - // wait for Etherpad to split two lines into separated divs - const $secondLine = inner$('div').first().next(); - return $secondLine.text().indexOf('line 2') === 0; - }); - - const $lineWithColon = inner$('div').first(); - $lineWithColon.sendkeys(':'); - pressEnter(); - const $lineAfterColon = inner$('div').first().next(); - // previous line indentation + regular tab (4 spaces) - expect($lineAfterColon.text()).to.match(/\s{6}/); - }); - - it("issue #2772 shows '*' when multiple indented lines " + - ' receive a style and are outdented', async function () { - this.timeout(1200); - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // make sure pad has more than one line - inner$('div').first().sendkeys('First{enter}Second{enter}'); - await helper.waitForPromise(() => inner$('div').first().text().trim() === 'First'); - - // indent first 2 lines - const $lines = inner$('div'); - const $firstLine = $lines.first(); - let $secondLine = $lines.slice(1, 2); - helper.selectLines($firstLine, $secondLine); - - const $indentButton = chrome$('.buttonicon-indent'); - $indentButton.trigger('click'); - - await helper.waitForPromise(() => inner$('div').first().find('ul li').length === 1); - - // apply bold - const $boldButton = chrome$('.buttonicon-bold'); - $boldButton.trigger('click'); - - await helper.waitForPromise(() => inner$('div').first().find('b').length === 1); - - // outdent first 2 lines - const $outdentButton = chrome$('.buttonicon-outdent'); - $outdentButton.trigger('click'); - await helper.waitForPromise(() => inner$('div').first().find('ul li').length === 0); - - // check if '*' is displayed - $secondLine = inner$('div').slice(1, 2); - expect($secondLine.text().trim()).to.be('Second'); - }); - - xit('makes text indented and outdented', async function () { - // get the inner iframe - const $inner = helper.$getPadInner(); - - // get the first text element out of the inner iframe - let firstTextElement = $inner.find('div').first(); - - // select this text element - helper.selectText(firstTextElement[0], $inner); - - // get the indentation button and click it - const $indentButton = helper.$getPadChrome().find('.buttonicon-indent'); - $indentButton.trigger('click'); - - let newFirstTextElement = $inner.find('div').first(); - - // is there a list-indent class element now? - let firstChild = newFirstTextElement.children(':first'); - let isUL = firstChild.is('ul'); - - // expect it to be the beginning of a list - expect(isUL).to.be(true); - - let secondChild = firstChild.children(':first'); - let isLI = secondChild.is('li'); - // expect it to be part of a list - expect(isLI).to.be(true); - - // indent again - $indentButton.trigger('click'); - - newFirstTextElement = $inner.find('div').first(); - - // is there a list-indent class element now? - firstChild = newFirstTextElement.children(':first'); - const hasListIndent2 = firstChild.hasClass('list-indent2'); - - // expect it to be part of a list - expect(hasListIndent2).to.be(true); - - // make sure the text hasn't changed - expect(newFirstTextElement.text()).to.eql(firstTextElement.text()); - - - // test outdent - - // get the unindentation button and click it twice - const $outdentButton = helper.$getPadChrome().find('.buttonicon-outdent'); - $outdentButton.trigger('click'); - $outdentButton.trigger('click'); - - newFirstTextElement = $inner.find('div').first(); - - // is there a list-indent class element now? - firstChild = newFirstTextElement.children(':first'); - isUL = firstChild.is('ul'); - - // expect it not to be the beginning of a list - expect(isUL).to.be(false); - - secondChild = firstChild.children(':first'); - isLI = secondChild.is('li'); - // expect it to not be part of a list - expect(isLI).to.be(false); - - // make sure the text hasn't changed - expect(newFirstTextElement.text()).to.eql(firstTextElement.text()); - - - // Next test tests multiple line indentation - - // select this text element - helper.selectText(firstTextElement[0], $inner); - - // indent twice - $indentButton.trigger('click'); - $indentButton.trigger('click'); - - // get the first text element out of the inner iframe - firstTextElement = $inner.find('div').first(); - - // select this text element - helper.selectText(firstTextElement[0], $inner); - - /* this test creates the below content, both should have double indentation - line1 - line2 - */ - - firstTextElement.sendkeys('{rightarrow}'); // simulate a keypress of enter - firstTextElement.sendkeys('{enter}'); // simulate a keypress of enter - firstTextElement.sendkeys('line 1'); // simulate writing the first line - firstTextElement.sendkeys('{enter}'); // simulate a keypress of enter - firstTextElement.sendkeys('line 2'); // simulate writing the second line - - // get the second text element out of the inner iframe - await new Promise((resolve) => setTimeout(resolve, 1000)); // THIS IS REALLY BAD - - const secondTextElement = $('iframe').contents() - .find('iframe').contents() - .find('iframe').contents().find('body > div').get(1); // THIS IS UGLY - - // is there a list-indent class element now? - firstChild = secondTextElement.children(':first'); - isUL = firstChild.is('ul'); - - // expect it to be the beginning of a list - expect(isUL).to.be(true); - - secondChild = secondChild.children(':first'); - isLI = secondChild.is('li'); - // expect it to be part of a list - expect(isLI).to.be(true); - - // get the first text element out of the inner iframe - const thirdTextElement = $('iframe').contents() - .find('iframe').contents() - .find('iframe').contents() - .find('body > div').get(2); // THIS IS UGLY TOO - - // is there a list-indent class element now? - firstChild = thirdTextElement.children(':first'); - isUL = firstChild.is('ul'); - - // expect it to be the beginning of a list - expect(isUL).to.be(true); - - secondChild = firstChild.children(':first'); - isLI = secondChild.is('li'); - - // expect it to be part of a list - expect(isLI).to.be(true); - }); -}); - -const pressEnter = () => { - const inner$ = helper.padInner$; - const e = new inner$.Event(helper.evtType); - e.keyCode = 13; // enter :| - inner$('#innerdocbody').trigger(e); -}; diff --git a/src/tests/frontend/specs/inner_height.js b/src/tests/frontend/specs/inner_height.js deleted file mode 100644 index d1a6b118b..000000000 --- a/src/tests/frontend/specs/inner_height.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -describe('height regression after ace.js refactoring', function () { - before(async function () { - await helper.aNewPad(); - }); - - // everything fits inside the viewport - it('clientHeight should equal scrollHeight with few lines', async function () { - await helper.clearPad(); - const outerHtml = helper.padChrome$('iframe')[0].contentDocument.documentElement; - // Give some time for the heights to settle. - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(outerHtml.clientHeight).to.be(outerHtml.scrollHeight); - }); - - it('client height should be less than scrollHeight with many lines', async function () { - await helper.clearPad(); - await helper.edit('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 outerHtml = helper.padChrome$('iframe')[0].contentDocument.documentElement; - // Need to poll because the heights take some time to settle. - await helper.waitForPromise(() => outerHtml.clientHeight < outerHtml.scrollHeight); - }); -}); diff --git a/src/tests/frontend/specs/italic.js b/src/tests/frontend/specs/italic.js deleted file mode 100644 index 3297d9399..000000000 --- a/src/tests/frontend/specs/italic.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict'; - -describe('italic some text', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('makes text italic using button', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - // get the bold button and click it - const $boldButton = chrome$('.buttonicon-italic'); - $boldButton.trigger('click'); - - // ace creates a new dom element when you press a button, just get the first text element again - const $newFirstTextElement = inner$('div').first(); - - // is there a element now? - const isItalic = $newFirstTextElement.find('i').length === 1; - - // expect it to be bold - expect(isItalic).to.be(true); - - // make sure the text hasn't changed - expect($newFirstTextElement.text()).to.eql($firstTextElement.text()); - }); - - it('makes text italic using keypress', async function () { - const inner$ = helper.padInner$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - const e = new inner$.Event(helper.evtType); - e.ctrlKey = true; // Control key - e.which = 105; // i - inner$('#innerdocbody').trigger(e); - - // ace creates a new dom element when you press a button, just get the first text element again - const $newFirstTextElement = inner$('div').first(); - - // is there a element now? - const isItalic = $newFirstTextElement.find('i').length === 1; - - // expect it to be bold - expect(isItalic).to.be(true); - - // make sure the text hasn't changed - expect($newFirstTextElement.text()).to.eql($firstTextElement.text()); - }); -}); diff --git a/src/tests/frontend/specs/language.js b/src/tests/frontend/specs/language.js deleted file mode 100644 index de3b483ec..000000000 --- a/src/tests/frontend/specs/language.js +++ /dev/null @@ -1,121 +0,0 @@ -'use strict'; - -describe('Language select and change', function () { - // Destroy language cookies - window.Cookies.remove('language'); - - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - // Destroy language cookies - it('makes text german', async function () { - this.timeout(1000); - const chrome$ = helper.padChrome$; - - // click on the settings button to make settings visible - const $settingsButton = chrome$('.buttonicon-settings'); - $settingsButton.trigger('click'); - - // click the language button - const $language = chrome$('#languagemenu'); - const $languageoption = $language.find('[value=de]'); - - // select german - $languageoption.attr('selected', 'selected'); - $language.trigger('change'); - - await helper.waitForPromise( - () => chrome$('.buttonicon-bold').parent()[0].title === 'Fett (Strg-B)'); - - // get the value of the bold button - const $boldButton = chrome$('.buttonicon-bold').parent(); - - // get the title of the bold button - const boldButtonTitle = $boldButton[0].title; - - // check if the language is now german - expect(boldButtonTitle).to.be('Fett (Strg-B)'); - }); - - it('makes text English', async function () { - this.timeout(1000); - const chrome$ = helper.padChrome$; - - // click on the settings button to make settings visible - const $settingsButton = chrome$('.buttonicon-settings'); - $settingsButton.trigger('click'); - - // click the language button - const $language = chrome$('#languagemenu'); - // select english - $language.val('en'); - $language.trigger('change'); - - // get the value of the bold button - let $boldButton = chrome$('.buttonicon-bold').parent(); - - await helper.waitForPromise(() => $boldButton[0].title !== 'Fett (Strg+B)'); - - // get the value of the bold button - $boldButton = chrome$('.buttonicon-bold').parent(); - - // get the title of the bold button - const boldButtonTitle = $boldButton[0].title; - - // check if the language is now English - expect(boldButtonTitle).to.be('Bold (Ctrl+B)'); - }); - - it('changes direction when picking an rtl lang', async function () { - // TODO: flaky - if (window.bowser.safari) { - this.timeout(5000); - } else { - this.timeout(1000); - } - const chrome$ = helper.padChrome$; - - // click on the settings button to make settings visible - const $settingsButton = chrome$('.buttonicon-settings'); - $settingsButton.trigger('click'); - - // click the language button - const $language = chrome$('#languagemenu'); - const $languageoption = $language.find('[value=ar]'); - - // select arabic - // $languageoption.attr('selected','selected'); // Breaks the test.. - $language.val('ar'); - $languageoption.trigger('change'); - - await helper.waitForPromise(() => chrome$('html')[0].dir !== 'ltr'); - - // check if the document's direction was changed - expect(chrome$('html')[0].dir).to.be('rtl'); - }); - - it('changes direction when picking an ltr lang', async function () { - const chrome$ = helper.padChrome$; - - // click on the settings button to make settings visible - const $settingsButton = chrome$('.buttonicon-settings'); - $settingsButton.trigger('click'); - - // click the language button - const $language = chrome$('#languagemenu'); - const $languageoption = $language.find('[value=en]'); - - // select english - // select arabic - $languageoption.attr('selected', 'selected'); - $language.val('en'); - $languageoption.trigger('change'); - - await helper.waitForPromise(() => chrome$('html')[0].dir !== 'rtl'); - - // check if the document's direction was changed - expect(chrome$('html')[0].dir).to.be('ltr'); - }); -}); diff --git a/src/tests/frontend/specs/ordered_list.js b/src/tests/frontend/specs/ordered_list.js deleted file mode 100644 index 33e7f5c90..000000000 --- a/src/tests/frontend/specs/ordered_list.js +++ /dev/null @@ -1,233 +0,0 @@ -'use strict'; - -describe('ordered_list.js', function () { - describe('assign ordered list', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('inserts ordered list text', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist'); - $insertorderedlistButton.trigger('click'); - - await helper.waitForPromise(() => inner$('div').first().find('ol li').length === 1); - }); - - context('when user presses Ctrl+Shift+N', function () { - context('and pad shortcut is enabled', function () { - beforeEach(async function () { - const originalHTML = helper.padInner$('body').html(); - makeSureShortcutIsEnabled('cmdShiftN'); - triggerCtrlShiftShortcut('N'); - await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML); - }); - - it('inserts unordered list', async function () { - await helper.waitForPromise( - () => helper.padInner$('div').first().find('ol li').length === 1); - }); - }); - - context('and pad shortcut is disabled', function () { - beforeEach(async function () { - const originalHTML = helper.padInner$('body').html(); - makeSureShortcutIsDisabled('cmdShiftN'); - triggerCtrlShiftShortcut('N'); - try { - // The HTML should not change. Briefly wait for it to change and fail if it does change. - await helper.waitForPromise( - () => helper.padInner$('body').html() !== originalHTML, 500); - } catch (err) { - // We want the test to pass if the above wait timed out. (If it timed out that - // means the HTML never changed, which is a good thing.) - // TODO: Re-throw non-"condition never became true" errors to avoid false positives. - } - // This will fail if the above `waitForPromise()` succeeded. - expect(helper.padInner$('body').html()).to.be(originalHTML); - }); - - it('does not insert unordered list', async function () { - this.timeout(3000); - try { - await helper.waitForPromise( - () => helper.padInner$('div').first().find('ol li').length === 1); - } catch (err) { - return; - } - expect().fail('Unordered list inserted, should ignore shortcut'); - }); - }); - }); - - context('when user presses Ctrl+Shift+1', function () { - context('and pad shortcut is enabled', function () { - beforeEach(async function () { - const originalHTML = helper.padInner$('body').html(); - makeSureShortcutIsEnabled('cmdShift1'); - triggerCtrlShiftShortcut('1'); - await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML); - }); - - it('inserts unordered list', async function () { - helper.waitForPromise(() => helper.padInner$('div').first().find('ol li').length === 1); - }); - }); - - context('and pad shortcut is disabled', function () { - beforeEach(async function () { - const originalHTML = helper.padInner$('body').html(); - makeSureShortcutIsDisabled('cmdShift1'); - triggerCtrlShiftShortcut('1'); - try { - // The HTML should not change. Briefly wait for it to change and fail if it does change. - await helper.waitForPromise( - () => helper.padInner$('body').html() !== originalHTML, 500); - } catch (err) { - // We want the test to pass if the above wait timed out. (If it timed out that - // means the HTML never changed, which is a good thing.) - // TODO: Re-throw non-"condition never became true" errors to avoid false positives. - } - // This will fail if the above `waitForPromise()` succeeded. - expect(helper.padInner$('body').html()).to.be(originalHTML); - }); - - it('does not insert unordered list', async function () { - this.timeout(3000); - try { - await helper.waitForPromise( - () => helper.padInner$('div').first().find('ol li').length === 1); - } catch (err) { - return; - } - expect().fail('Unordered list inserted, should ignore shortcut'); - }); - }); - }); - - it('issue #4748 keeps numbers increment on OL', async function () { - this.timeout(5000); - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist'); - const $firstLine = inner$('div').first(); - $firstLine.sendkeys('{selectall}'); - $insertorderedlistButton.trigger('click'); - const $secondLine = inner$('div').first().next(); - $secondLine.sendkeys('{selectall}'); - $insertorderedlistButton.trigger('click'); - expect($secondLine.find('ol').attr('start') === 2); - }); - - xit('issue #1125 keeps the numbered list on enter for the new line', async function () { - // EMULATES PASTING INTO A PAD - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist'); - $insertorderedlistButton.trigger('click'); - - // type a bit, make a line break and type again - const $firstTextElement = inner$('div span').first(); - $firstTextElement.sendkeys('line 1'); - $firstTextElement.sendkeys('{enter}'); - $firstTextElement.sendkeys('line 2'); - $firstTextElement.sendkeys('{enter}'); - - await helper.waitForPromise(() => inner$('div span').first().text().indexOf('line 2') === -1); - - const $newSecondLine = inner$('div').first().next(); - const hasOLElement = $newSecondLine.find('ol li').length === 1; - expect(hasOLElement).to.be(true); - expect($newSecondLine.text()).to.be('line 2'); - const hasLineNumber = $newSecondLine.find('ol').attr('start') === 2; - // This doesn't work because pasting in content doesn't work - expect(hasLineNumber).to.be(true); - }); - - const triggerCtrlShiftShortcut = (shortcutChar) => { - const inner$ = helper.padInner$; - const e = new inner$.Event(helper.evtType); - e.ctrlKey = true; - e.shiftKey = true; - e.which = shortcutChar.toString().charCodeAt(0); - inner$('#innerdocbody').trigger(e); - }; - - const makeSureShortcutIsDisabled = (shortcut) => { - helper.padChrome$.window.clientVars.padShortcutEnabled[shortcut] = false; - }; - const makeSureShortcutIsEnabled = (shortcut) => { - helper.padChrome$.window.clientVars.padShortcutEnabled[shortcut] = true; - }; - }); - - describe('Pressing Tab in an OL increases and decreases indentation', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('indent and de-indent list item with keypress', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist'); - $insertorderedlistButton.trigger('click'); - - const e = new inner$.Event(helper.evtType); - e.keyCode = 9; // tab - inner$('#innerdocbody').trigger(e); - - expect(inner$('div').first().find('.list-number2').length === 1).to.be(true); - e.shiftKey = true; // shift - e.keyCode = 9; // tab - inner$('#innerdocbody').trigger(e); - - await helper.waitForPromise(() => inner$('div').first().find('.list-number1').length === 1); - }); - }); - - - describe('Pressing indent/outdent button in an OL increases and ' + - 'decreases indentation and bullet / ol formatting', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('indent and de-indent list item with indent button', async function () { - this.timeout(1000); - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist'); - $insertorderedlistButton.trigger('click'); - - const $indentButton = chrome$('.buttonicon-indent'); - $indentButton.trigger('click'); // make it indented twice - - expect(inner$('div').first().find('.list-number2').length === 1).to.be(true); - - const $outdentButton = chrome$('.buttonicon-outdent'); - $outdentButton.trigger('click'); // make it deindented to 1 - - await helper.waitForPromise(() => inner$('div').first().find('.list-number1').length === 1); - }); - }); -}); diff --git a/src/tests/frontend/specs/redo.js b/src/tests/frontend/specs/redo.js deleted file mode 100644 index 6a29482e2..000000000 --- a/src/tests/frontend/specs/redo.js +++ /dev/null @@ -1,59 +0,0 @@ -'use strict'; - -describe('undo button then redo button', function () { - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('redo some typing with button', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element inside the editable space - const $firstTextElement = inner$('div span').first(); - const originalValue = $firstTextElement.text(); // get the original value - const newString = 'Foo'; - - $firstTextElement.sendkeys(newString); // send line 1 to the pad - const modifiedValue = $firstTextElement.text(); // get the modified value - expect(modifiedValue).not.to.be(originalValue); // expect the value to change - - // get undo and redo buttons - const $undoButton = chrome$('.buttonicon-undo'); - const $redoButton = chrome$('.buttonicon-redo'); - // click the buttons - $undoButton.trigger('click'); // removes foo - $redoButton.trigger('click'); // resends foo - - await helper.waitForPromise(() => inner$('div span').first().text() === newString); - const finalValue = inner$('div').first().text(); - expect(finalValue).to.be(modifiedValue); // expect the value to change - }); - - it('redo some typing with keypress', async function () { - const inner$ = helper.padInner$; - - // get the first text element inside the editable space - const $firstTextElement = inner$('div span').first(); - const originalValue = $firstTextElement.text(); // get the original value - const newString = 'Foo'; - - $firstTextElement.sendkeys(newString); // send line 1 to the pad - const modifiedValue = $firstTextElement.text(); // get the modified value - expect(modifiedValue).not.to.be(originalValue); // expect the value to change - - let e = new inner$.Event(helper.evtType); - e.ctrlKey = true; // Control key - e.which = 90; // z - inner$('#innerdocbody').trigger(e); - - e = new inner$.Event(helper.evtType); - e.ctrlKey = true; // Control key - e.which = 121; // y - inner$('#innerdocbody').trigger(e); - - await helper.waitForPromise(() => inner$('div span').first().text() === newString); - const finalValue = inner$('div').first().text(); - expect(finalValue).to.be(modifiedValue); // expect the value to change - }); -}); diff --git a/src/tests/frontend/specs/strikethrough.js b/src/tests/frontend/specs/strikethrough.js deleted file mode 100644 index 8385d676e..000000000 --- a/src/tests/frontend/specs/strikethrough.js +++ /dev/null @@ -1,35 +0,0 @@ -'use strict'; - -describe('strikethrough button', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('makes text strikethrough', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - // get the strikethrough button and click it - const $strikethroughButton = chrome$('.buttonicon-strikethrough'); - $strikethroughButton.trigger('click'); - - // ace creates a new dom element when you press a button, just get the first text element again - const $newFirstTextElement = inner$('div').first(); - - // is there a element now? - const isstrikethrough = $newFirstTextElement.find('s').length === 1; - - // expect it to be strikethrough - expect(isstrikethrough).to.be(true); - - // make sure the text hasn't changed - expect($newFirstTextElement.text()).to.eql($firstTextElement.text()); - }); -}); diff --git a/src/tests/frontend/specs/timeslider.js b/src/tests/frontend/specs/timeslider.js deleted file mode 100644 index 6ee00f108..000000000 --- a/src/tests/frontend/specs/timeslider.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -// deactivated, we need a nice way to get the timeslider, this is ugly -xdescribe('timeslider button takes you to the timeslider of a pad', function () { - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('timeslider contained in URL', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element inside the editable space - const $firstTextElement = inner$('div span').first(); - const originalValue = $firstTextElement.text(); // get the original value - $firstTextElement.sendkeys('Testing'); // send line 1 to the pad - - const modifiedValue = $firstTextElement.text(); // get the modified value - expect(modifiedValue).not.to.be(originalValue); // expect the value to change - - // The value has changed so we can.. - await helper.waitForPromise(() => modifiedValue !== originalValue); - - const $timesliderButton = chrome$('#timesliderlink'); - $timesliderButton.trigger('click'); // So click the timeslider link - - await helper.waitForPromise(() => { - const iFrameURL = chrome$.window.location.href; - if (iFrameURL) { - return iFrameURL.indexOf('timeslider') !== -1; - } else { - return false; // the URL hasnt been set yet - } - }); - - // click the buttons - const iFrameURL = chrome$.window.location.href; // get the url - const inTimeslider = iFrameURL.indexOf('timeslider') !== -1; - expect(inTimeslider).to.be(true); // expect the value to change - }); -}); diff --git a/src/tests/frontend/specs/timeslider_follow.js b/src/tests/frontend/specs/timeslider_follow.js deleted file mode 100644 index e3d9b8067..000000000 --- a/src/tests/frontend/specs/timeslider_follow.js +++ /dev/null @@ -1,101 +0,0 @@ -'use strict'; - -describe('timeslider follow', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - // TODO needs test if content is also followed, when user a makes edits - // while user b is in the timeslider - it("content as it's added to timeslider", async function () { - this.timeout(20000); - // 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 helper.edit(message, newLines * i + 1); - } - - await helper.gotoTimeslider(0); - await helper.waitForPromise(() => helper.contentWindow().location.hash === '#0'); - - const originalTop = helper.contentWindow().$('#innerdocbody').offset(); - - // set to follow contents as it arrives - helper.contentWindow().$('#options-followContents').prop('checked', true); - helper.contentWindow().$('#playpause_button_icon').trigger('click'); - - let newTop; - await helper.waitForPromise(() => { - newTop = helper.contentWindow().$('#innerdocbody').offset(); - return newTop.top < originalTop.top; - }); - }); - - /** - * 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. - */ - it('only to lines that exist in the pad view, regression test for #4389', async function () { - await helper.clearPad(); - await helper.edit('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 helper.edit('Another test line', 40); - - - await helper.gotoTimeslider(); - - // set to follow contents as it arrives - helper.contentWindow().$('#options-followContents').prop('checked', true); - - const oldYPosition = helper.contentWindow().$('#editorcontainerbox')[0].scrollTop; - expect(oldYPosition).to.be(0); - - /** - * pad content rev 0 [default Pad text] - * pad content rev 1 [''] - * pad content rev 2 ['Test line','','', ..., ''] - * pad content rev 3 ['Test line','',..., 'Another test line', ..., ''] - */ - - // line 40 changed - helper.contentWindow().$('#leftstep').trigger('click'); - await helper.waitForPromise(() => hasFollowedToLine(40)); - - // line 1 is the first line that changed - helper.contentWindow().$('#leftstep').trigger('click'); - await helper.waitForPromise(() => hasFollowedToLine(1)); - - // line 1 changed - helper.contentWindow().$('#leftstep').trigger('click'); - await helper.waitForPromise(() => hasFollowedToLine(1)); - - // line 1 changed - helper.contentWindow().$('#rightstep').trigger('click'); - await helper.waitForPromise(() => hasFollowedToLine(1)); - - // line 1 is the first line that changed - helper.contentWindow().$('#rightstep').trigger('click'); - await helper.waitForPromise(() => hasFollowedToLine(1)); - - // line 40 changed - helper.contentWindow().$('#rightstep').trigger('click'); - helper.waitForPromise(() => hasFollowedToLine(40)); - }); -}); - -/** - * @param {number} lineNum - * @returns {boolean} scrolled to the lineOffset? - */ -const hasFollowedToLine = (lineNum) => { - const scrollPosition = helper.contentWindow().$('#editorcontainerbox')[0].scrollTop; - const lineOffset = - helper.contentWindow().$('#innerdocbody').find(`div:nth-child(${lineNum})`)[0].offsetTop; - return Math.abs(scrollPosition - lineOffset) < 1; -}; diff --git a/src/tests/frontend/specs/undo.js b/src/tests/frontend/specs/undo.js deleted file mode 100644 index 4d9b4f18a..000000000 --- a/src/tests/frontend/specs/undo.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; - -describe('undo button', function () { - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('undo some typing by clicking undo button', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element inside the editable space - const $firstTextElement = inner$('div span').first(); - const originalValue = $firstTextElement.text(); // get the original value - - $firstTextElement.sendkeys('foo'); // send line 1 to the pad - const modifiedValue = $firstTextElement.text(); // get the modified value - expect(modifiedValue).not.to.be(originalValue); // expect the value to change - - // get clear authorship button as a variable - const $undoButton = chrome$('.buttonicon-undo'); - // click the button - $undoButton.trigger('click'); - - await helper.waitForPromise(() => inner$('div span').first().text() === originalValue); - }); - - it('undo some typing using a keypress', async function () { - const inner$ = helper.padInner$; - - // get the first text element inside the editable space - const $firstTextElement = inner$('div span').first(); - const originalValue = $firstTextElement.text(); // get the original value - - $firstTextElement.sendkeys('foo'); // send line 1 to the pad - const modifiedValue = $firstTextElement.text(); // get the modified value - expect(modifiedValue).not.to.be(originalValue); // expect the value to change - - const e = new inner$.Event(helper.evtType); - e.ctrlKey = true; // Control key - e.which = 90; // z - inner$('#innerdocbody').trigger(e); - - await helper.waitForPromise(() => inner$('div span').first().text() === originalValue); - }); -}); diff --git a/src/tests/frontend/specs/unordered_list.js b/src/tests/frontend/specs/unordered_list.js deleted file mode 100644 index f181bb17b..000000000 --- a/src/tests/frontend/specs/unordered_list.js +++ /dev/null @@ -1,146 +0,0 @@ -'use strict'; - -describe('unordered_list.js', function () { - describe('assign unordered list', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('insert unordered list text then removes by outdent', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - const originalText = inner$('div').first().text(); - - const $insertunorderedlistButton = chrome$('.buttonicon-insertunorderedlist'); - $insertunorderedlistButton.trigger('click'); - - await helper.waitForPromise(() => { - const newText = inner$('div').first().text(); - return newText === originalText && inner$('div').first().find('ul li').length === 1; - }); - - // remove indentation by bullet and ensure text string remains the same - chrome$('.buttonicon-outdent').trigger('click'); - await helper.waitForPromise(() => inner$('div').first().text() === originalText); - }); - }); - - describe('unassign unordered list', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('insert unordered list text then remove by clicking list again', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - const originalText = inner$('div').first().text(); - - const $insertunorderedlistButton = chrome$('.buttonicon-insertunorderedlist'); - $insertunorderedlistButton.trigger('click'); - - await helper.waitForPromise(() => { - const newText = inner$('div').first().text(); - return newText === originalText && inner$('div').first().find('ul li').length === 1; - }); - - // remove indentation by bullet and ensure text string remains the same - $insertunorderedlistButton.trigger('click'); - await helper.waitForPromise(() => inner$('div').find('ul').length !== 1); - }); - }); - - - describe('keep unordered list on enter key', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('Keeps the unordered list on enter for the new line', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - const $insertorderedlistButton = chrome$('.buttonicon-insertunorderedlist'); - $insertorderedlistButton.trigger('click'); - - // type a bit, make a line break and type again - const $firstTextElement = inner$('div span').first(); - $firstTextElement.sendkeys('line 1'); - $firstTextElement.sendkeys('{enter}'); - $firstTextElement.sendkeys('line 2'); - $firstTextElement.sendkeys('{enter}'); - - await helper.waitForPromise(() => inner$('div span').first().text().indexOf('line 2') === -1); - - const $newSecondLine = inner$('div').first().next(); - const hasULElement = $newSecondLine.find('ul li').length === 1; - expect(hasULElement).to.be(true); - expect($newSecondLine.text()).to.be('line 2'); - }); - }); - - describe('Pressing Tab in an UL increases and decreases indentation', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('indent and de-indent list item with keypress', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - const $insertorderedlistButton = chrome$('.buttonicon-insertunorderedlist'); - $insertorderedlistButton.trigger('click'); - - const e = new inner$.Event(helper.evtType); - e.keyCode = 9; // tab - inner$('#innerdocbody').trigger(e); - - expect(inner$('div').first().find('.list-bullet2').length === 1).to.be(true); - e.shiftKey = true; // shift - e.keyCode = 9; // tab - inner$('#innerdocbody').trigger(e); - - await helper.waitForPromise(() => inner$('div').first().find('.list-bullet1').length === 1); - }); - }); - - describe('Pressing indent/outdent button in an UL increases and decreases indentation ' + - 'and bullet / ol formatting', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('indent and de-indent list item with indent button', async function () { - const inner$ = helper.padInner$; - const chrome$ = helper.padChrome$; - - // get the first text element out of the inner iframe - const $firstTextElement = inner$('div').first(); - - // select this text element - $firstTextElement.sendkeys('{selectall}'); - - const $insertunorderedlistButton = chrome$('.buttonicon-insertunorderedlist'); - $insertunorderedlistButton.trigger('click'); - - const $indentButton = chrome$('.buttonicon-indent'); - $indentButton.trigger('click'); // make it indented twice - - expect(inner$('div').first().find('.list-bullet2').length === 1).to.be(true); - const $outdentButton = chrome$('.buttonicon-outdent'); - $outdentButton.trigger('click'); // make it deindented to 1 - - await helper.waitForPromise(() => inner$('div').first().find('.list-bullet1').length === 1); - }); - }); -}); diff --git a/src/tests/frontend/specs/urls_become_clickable.js b/src/tests/frontend/specs/urls_become_clickable.js deleted file mode 100644 index bb3f00b34..000000000 --- a/src/tests/frontend/specs/urls_become_clickable.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -describe('urls', function () { - // Returns the first text element. Note that any change to the text element will result in the - // element being replaced with another object. - const txt = () => helper.padInner$('div').first(); - - before(async function () { - await helper.aNewPad(); - }); - - beforeEach(async function () { - await helper.clearPad(); - }); - - describe('entering a URL makes a link', function () { - for (const url of ['https://etherpad.org', 'www.etherpad.org']) { - it(url, async function () { - this.timeout(5000); - const url = 'https://etherpad.org'; - await helper.edit(url); - await helper.waitForPromise(() => txt().find('a').length === 1, 2000); - const link = txt().find('a'); - expect(link.attr('href')).to.be(url); - expect(link.text()).to.be(url); - }); - } - }); - - describe('special characters inside URL', function () { - for (const char of '-:@_.,~%+/?=&#!;()[]$\'*') { - const url = `https://etherpad.org/${char}foo`; - it(url, async function () { - await helper.edit(url); - await helper.waitForPromise(() => txt().find('a').length === 1); - const link = txt().find('a'); - expect(link.attr('href')).to.be(url); - expect(link.text()).to.be(url); - }); - } - }); - - describe('punctuation after URL is ignored', function () { - for (const char of ':.,;?!)]\'*') { - const want = 'https://etherpad.org'; - const input = want + char; - it(input, async function () { - await helper.edit(input); - await helper.waitForPromise(() => txt().find('a').length === 1); - const link = txt().find('a'); - expect(link.attr('href')).to.be(want); - expect(link.text()).to.be(want); - }); - } - }); -}); diff --git a/src/tests/frontend/travis/adminrunner.sh b/src/tests/frontend/travis/adminrunner.sh index 9ed6d5e74..32fd12a63 100755 --- a/src/tests/frontend/travis/adminrunner.sh +++ b/src/tests/frontend/travis/adminrunner.sh @@ -11,8 +11,8 @@ MY_DIR=$(try cd "${0%/*}" && try pwd -P) || exit 1 try cd "${MY_DIR}/../../../.." log "Assuming src/bin/installDeps.sh has already been run" -node src/node/server.js --experimental-worker "${@}" & -ep_pid=$! +( cd src && npm run dev --experimental-worker "${@}" & +ep_pid=$!) log "Waiting for Etherpad to accept connections (http://localhost:9001)..." connected=false diff --git a/src/tests/frontend/travis/runner.sh b/src/tests/frontend/travis/runner.sh index 23bc13ed7..c2c2907e3 100755 --- a/src/tests/frontend/travis/runner.sh +++ b/src/tests/frontend/travis/runner.sh @@ -10,9 +10,9 @@ try() { "$@" || fatal "'$@' failed"; } MY_DIR=$(try cd "${0%/*}" && try pwd -P) || exit 1 try cd "${MY_DIR}/../../../.." -log "Assuming src/bin/installDeps.sh has already been run" -node src/node/server.js --experimental-worker "${@}" & -ep_pid=$! +log "Assuming bin/installDeps.sh has already been run" +(cd src && npm run dev --experimental-worker "${@}" & +ep_pid=$!) log "Waiting for Etherpad to accept connections (http://localhost:9001)..." connected=false diff --git a/src/tests/frontend/travis/runnerBackend.sh b/src/tests/frontend/travis/runnerBackend.sh index f12ff25c1..518e77872 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" -node src/node/server.js "${@}" & -ep_pid=$! +(cd src && npm run dev "${@}" & +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 377d8e3c9..6582b4b51 100755 --- a/src/tests/frontend/travis/runnerLoadTest.sh +++ b/src/tests/frontend/travis/runnerLoadTest.sh @@ -24,8 +24,8 @@ s!"points":[^,]*!"points": 1000! ' settings.json.template >settings.json log "Assuming src/bin/installDeps.sh has already been run" -node src/node/server.js & -ep_pid=$! +(cd src && npm run dev & +ep_pid=$!) log "Waiting for Etherpad to accept connections (http://localhost:9001)..." connected=false diff --git a/src/tests/ratelimit/Dockerfile.anotherip b/src/tests/ratelimit/Dockerfile.anotherip index 57f02f628..c352b4af1 100644 --- a/src/tests/ratelimit/Dockerfile.anotherip +++ b/src/tests/ratelimit/Dockerfile.anotherip @@ -1,4 +1,4 @@ -FROM node:alpine3.12 +FROM node:latest 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 new file mode 100644 index 000000000..c8064176b --- /dev/null +++ b/src/tests/settings.json @@ -0,0 +1,654 @@ +/* + * This file must be valid JSON. But comments are allowed + * + * Please edit settings.json, not settings.json.template + * + * Please note that starting from Etherpad 1.6.0 you can store DB credentials in + * a separate file (credentials.json). + * + * + * ENVIRONMENT VARIABLE SUBSTITUTION + * ================================= + * + * All the configuration values can be read from environment variables using the + * syntax "${ENV_VAR}" or "${ENV_VAR:default_value}". + * + * This is useful, for example, when running in a Docker container. + * + * DETAILED RULES: + * - If the environment variable is set to the string "true" or "false", the + * value becomes Boolean true or false. + * - If the environment variable is set to the string "null", the value + * becomes null. + * - If the environment variable is set to the string "undefined", the setting + * is removed entirely, except when used as the member of an array in which + * case it becomes null. + * - If the environment variable is set to a string representation of a finite + * number, the string is converted to that number. + * - If the environment variable is set to any other string, including the + * empty string, the value is that string. + * - If the environment variable is unset and a default value is provided, the + * value is as if the environment variable was set to the provided default: + * - "${UNSET_VAR:}" becomes the empty string. + * - "${UNSET_VAR:foo}" becomes the string "foo". + * - "${UNSET_VAR:true}" and "${UNSET_VAR:false}" become true and false. + * - "${UNSET_VAR:null}" becomes null. + * - "${UNSET_VAR:undefined}" causes the setting to be removed (or be set + * to null, if used as a member of an array). + * - If the environment variable is unset and no default value is provided, + * the value becomes null. THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF + * ETHERPAD; if you want the default value to be null, you should explicitly + * specify "null" as the default value. + * + * EXAMPLE: + * "port": "${PORT:9001}" + * "minify": "${MINIFY}" + * "skinName": "${SKIN_NAME:colibris}" + * + * Would read the configuration values for those items from the environment + * variables PORT, MINIFY and SKIN_NAME. + * + * If PORT and SKIN_NAME variables were not defined, the default values 9001 and + * "colibris" would be used. + * The configuration value "minify", on the other hand, does not have a + * designated default value. Thus, if the environment variable MINIFY were + * undefined, "minify" would be null. + * + * REMARKS: + * 1) please note that variable substitution always needs to be quoted. + * + * "port": 9001, <-- Literal values. When not using + * "minify": false substitution, only strings must be + * "skinName": "colibris" quoted. Booleans and numbers must not. + * + * "port": "${PORT:9001}" <-- CORRECT: if you want to use a variable + * "minify": "${MINIFY:true}" substitution, put quotes around its name, + * "skinName": "${SKIN_NAME}" even if the required value is a number or + * a boolean. + * Etherpad will take care of rewriting it + * to the proper type if necessary. + * + * "port": ${PORT:9001} <-- ERROR: this is not valid json. Quotes + * "minify": ${MINIFY} around variable names are missing. + * "skinName": ${SKIN_NAME} + * + * 2) Beware of undefined variables and default values: nulls and empty strings + * are different! + * + * This is particularly important for user's passwords (see the relevant + * section): + * + * "password": "${PASSW}" // if PASSW is not defined would result in password === null + * "password": "${PASSW:}" // if PASSW is not defined would result in password === '' + * + * If you want to use an empty value (null) as default value for a variable, + * simply do not set it, without putting any colons: "${ABIWORD}". + * + * 3) if you want to use newlines in the default value of a string parameter, + * use "\n" as usual. + * + * "defaultPadText" : "${DEFAULT_PAD_TEXT}Line 1\nLine 2" + */ +{ + /* + * Name your instance! + */ + "title": "Etherpad", + + /* + * Pathname of the favicon you want to use. If null, the skin's favicon is + * used if one is provided by the skin, otherwise the default Etherpad favicon + * is used. If this is a relative path it is interpreted as relative to the + * Etherpad root directory. + */ + "favicon": null, + + /* + * Skin name. + * + * Its value has to be an existing directory under src/static/skins. + * You can write your own, or use one of the included ones: + * + * - "no-skin": an empty skin (default). This yields the unmodified, + * traditional Etherpad theme. + * - "colibris": the new experimental skin (since Etherpad 1.8), candidate to + * become the default in Etherpad 2.0 + */ + "skinName": "colibris", + + /* + * Skin Variants + * + * Use the UI skin variants builder at /p/test#skinvariantsbuilder + * + * For the colibris skin only, you can choose how to render the three main + * containers: + * - toolbar (top menu with icons) + * - editor (containing the text of the pad) + * - background (area outside of editor, mostly visible when using page style) + * + * For each of the 3 containers you can choose 4 color combinations: + * super-light, light, dark, super-dark. + * + * For example, to make the toolbar dark, you will include "dark-toolbar" into + * skinVariants. + * + * You can provide multiple skin variants separated by spaces. Default + * skinVariant is "super-light-toolbar super-light-editor light-background". + * + * For the editor container, you can also make it full width by adding + * "full-width-editor" variant (by default editor is rendered as a page, with + * a max-width of 900px). + */ + "skinVariants": "super-light-toolbar super-light-editor light-background", + + /* + * IP and port which Etherpad should bind at. + * + * Binding to a Unix socket is also supported: just use an empty string for + * the ip, and put the full path to the socket in the port parameter. + * + * EXAMPLE USING UNIX SOCKET: + * "ip": "", // <-- has to be an empty string + * "port" : "/somepath/etherpad.socket", // <-- path to a Unix socket + */ + "ip": "0.0.0.0", + "port": 9001, + + /* + * Option to hide/show the settings.json in admin page. + * + * Default option is set to true + */ + "showSettingsInAdminPage": true, + + /* + * Node native SSL support + * + * This is disabled by default. + * Make sure to have the minimum and correct file access permissions set so + * that the Etherpad server can access them + */ + + /* + "ssl" : { + "key" : "/path-to-your/epl-server.key", + "cert" : "/path-to-your/epl-server.crt", + "ca": ["/path-to-your/epl-intermediate-cert1.crt", "/path-to-your/epl-intermediate-cert2.crt"] + }, + */ + + /* + * The type of the database. + * + * You can choose between many DB drivers, for example: dirty, postgres, + * sqlite, mysql. + * + * You shouldn't use "dirty" for for anything else than testing or + * development. + * + * + * Database specific settings are dependent on dbType, and go in dbSettings. + * Remember that since Etherpad 1.6.0 you can also store this information in + * credentials.json. + * + * For a complete list of the supported drivers, please refer to: + * https://www.npmjs.com/package/ueberdb2 + */ + + "dbType": "dirty", + "dbSettings": { + "filename": "var/dirty.db" + }, + + /* + * An Example of MySQL Configuration (commented out). + * + * See: https://github.com/ether/etherpad-lite/wiki/How-to-use-Etherpad-Lite-with-MySQL + */ + + /* + "dbType" : "mysql", + "dbSettings" : { + "user": "etherpaduser", + "host": "localhost", + "port": 3306, + "password": "PASSWORD", + "database": "etherpad_lite_db", + "charset": "utf8mb4" + }, + */ + + /* + * The default text of a pad + */ + "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", + + /* + * Default Pad behavior. + * + * Change them if you want to override. + */ + "padOptions": { + "noColors": false, + "showControls": true, + "showChat": true, + "showLineNumbers": true, + "useMonospaceFont": false, + "userName": null, + "userColor": null, + "rtl": false, + "alwaysShowChat": false, + "chatAndUsers": false, + "lang": null + }, + + /* + * Pad Shortcut Keys + */ + "padShortcutEnabled" : { + "altF9": true, /* focus on the File Menu and/or editbar */ + "altC": true, /* focus on the Chat window */ + "cmdShift2": true, /* shows a gritter popup showing a line author */ + "delete": true, + "return": true, + "esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */ + "cmdS": true, /* save a revision */ + "tab": true, /* indent */ + "cmdZ": true, /* undo/redo */ + "cmdY": true, /* redo */ + "cmdI": true, /* italic */ + "cmdB": true, /* bold */ + "cmdU": true, /* underline */ + "cmd5": true, /* strike through */ + "cmdShiftL": true, /* unordered list */ + "cmdShiftN": true, /* ordered list */ + "cmdShift1": true, /* ordered list */ + "cmdShiftC": true, /* clear authorship */ + "cmdH": true, /* backspace */ + "ctrlHome": true, /* scroll to top of pad */ + "pageUp": true, + "pageDown": true + }, + + /* + * Should we suppress errors from being visible in the default Pad Text? + */ + "suppressErrorsInPadText": false, + + /* + * If this option is enabled, a user must have a session to access pads. + * This effectively allows only group pads to be accessed. + */ + "requireSession": false, + + /* + * Users may edit pads but not create new ones. + * + * Pad creation is only via the API. + * This applies both to group pads and regular pads. + */ + "editOnly": false, + + /* + * If true, all css & js will be minified before sending to the client. + * + * This will improve the loading performance massively, but makes it difficult + * to debug the javascript/css + */ + "minify": true, + + /* + * How long may clients use served javascript code (in seconds)? + * + * Not setting this may cause problems during deployment. + * Set to 0 to disable caching. + */ + "maxAge": 21600, // 60 * 60 * 6 = 6 hours + + /* + * Absolute path to the Abiword executable. + * + * Abiword is needed to get advanced import/export features of pads. Setting + * it to null disables Abiword and will only allow plain text and HTML + * import/exports. + */ + "abiword": null, + + /* + * This is the absolute path to the soffice executable. + * + * LibreOffice can be used in lieu of Abiword to export pads. + * Setting it to null disables LibreOffice exporting. + */ + "soffice": null, + + /* + * Allow import of file types other than the supported ones: + * txt, doc, docx, rtf, odt, html & htm + */ + "allowUnknownFileEnds": true, + + /* + * This setting is used if you require authentication of all users. + * + * Note: "/admin" always requires authentication. + */ + "requireAuthentication": false, + + /* + * Require authorization by a module, or a user with is_admin set, see below. + */ + "requireAuthorization": false, + + /* + * When you use NGINX or another proxy/load-balancer set this to true. + * + * This is especially necessary when the reverse proxy performs SSL + * termination, otherwise the cookies will not have the "secure" flag. + * + * The other effect will be that the logs will contain the real client's IP, + * instead of the reverse proxy's IP. + */ + "trustProxy": false, + + /* + * Settings controlling the session cookie issued by Etherpad. + */ + "cookie": { + /* + * How often (in milliseconds) the key used to sign the express_sid cookie + * should be rotated. Long rotation intervals reduce signature verification + * overhead (because there are fewer historical keys to check) and database + * load (fewer historical keys to store, and less frequent queries to + * get/update the keys). Short rotation intervals are slightly more secure. + * + * Multiple Etherpad processes sharing the same database (table) is + * supported as long as the clock sync error is significantly less than this + * value. + * + * Key rotation can be disabled (not recommended) by setting this to 0 or + * null, or by disabling session expiration (see sessionLifetime). + */ + "keyRotationInterval": 86400000, // = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s + + /* + * Value of the SameSite cookie property. "Lax" is recommended unless + * Etherpad will be embedded in an iframe from another site, in which case + * this must be set to "None". Note: "None" will not work (the browser will + * not send the cookie to Etherpad) unless https is used to access Etherpad + * (either directly or via a reverse proxy with "trustProxy" set to true). + * + * "Strict" is not recommended because it has few security benefits but + * significant usability drawbacks vs. "Lax". See + * https://stackoverflow.com/q/41841880 for discussion. + */ + "sameSite": "Lax", + + /* + * How long (in milliseconds) after navigating away from Etherpad before the + * user is required to log in again. (The express_sid cookie is set to + * expire at time now + sessionLifetime when first created, and its + * expiration time is periodically refreshed to a new now + sessionLifetime + * value.) If requireAuthentication is false then this value does not really + * matter. + * + * The "best" value depends on your users' usage patterns and the amount of + * convenience you desire. A long lifetime is more convenient (users won't + * have to log back in as often) but has some drawbacks: + * - It increases the amount of state kept in the database. + * - It might weaken security somewhat: The cookie expiration is refreshed + * indefinitely without consulting authentication or authorization + * hooks, so once a user has accessed a pad, the user can continue to + * use the pad until the user leaves for longer than sessionLifetime. + * - More historical keys (sessionLifetime / keyRotationInterval) must be + * checked when verifying signatures. + * + * Session lifetime can be set to infinity (not recommended) by setting this + * to null or 0. Note that if the session does not expire, most browsers + * will delete the cookie when the browser exits, but a session record is + * kept in the database forever. + */ + "sessionLifetime": 864000000, // = 10d * 24h/d * 60m/h * 60s/m * 1000ms/s + + /* + * How long (in milliseconds) before the expiration time of an active user's + * session is refreshed (to now + sessionLifetime). This setting affects the + * following: + * - How often a new session expiration time will be written to the + * database. + * - How often each user's browser will ping the Etherpad server to + * refresh the expiration time of the session cookie. + * + * High values reduce the load on the database and the load from browsers, + * but can shorten the effective session lifetime if Etherpad is restarted + * or the user navigates away. + * + * Automatic session refreshes can be disabled (not recommended) by setting + * this to null. + */ + "sessionRefreshInterval": 86400000 // = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s + }, + + /* + * Privacy: disable IP logging + */ + "disableIPlogging": false, + + /* + * Time (in seconds) to automatically reconnect pad when a "Force reconnect" + * message is shown to user. + * + * Set to 0 to disable automatic reconnection. + */ + "automaticReconnectionTimeout": 0, + + /* + * By default, when caret is moved out of viewport, it scrolls the minimum + * height needed to make this line visible. + */ + "scrollWhenFocusLineIsOutOfViewport": { + + /* + * Percentage of viewport height to be additionally scrolled. + * + * E.g.: use "percentage.editionAboveViewport": 0.5, to place caret line in + * the middle of viewport, when user edits a line above of the + * viewport + * + * Set to 0 to disable extra scrolling + */ + "percentage": { + "editionAboveViewport": 0, + "editionBelowViewport": 0 + }, + + /* + * Time (in milliseconds) used to animate the scroll transition. + * Set to 0 to disable animation + */ + "duration": 0, + + /* + * Flag to control if it should scroll when user places the caret in the + * last line of the viewport + */ + "scrollWhenCaretIsInTheLastLineOfViewport": false, + + /* + * Percentage of viewport height to be additionally scrolled when user + * presses arrow up in the line of the top of the viewport. + * + * Set to 0 to let the scroll to be handled as default by Etherpad + */ + "percentageToScrollWhenUserPressesArrowUp": 0 + }, + + /* + * User accounts. These accounts are used by: + * - default HTTP basic authentication if no plugin handles authentication + * - some but not all authentication plugins + * - some but not all authorization plugins + * + * User properties: + * - password: The user's password. Some authentication plugins will ignore + * this. + * - is_admin: true gives access to /admin. Defaults to false. If you do not + * uncomment this, /admin will not be available! + * - readOnly: If true, this user will not be able to create new pads or + * modify existing pads. Defaults to false. + * - canCreate: If this is true and readOnly is false, this user can create + * new pads. Defaults to true. + * + * Authentication and authorization plugins may define additional properties. + * + * WARNING: passwords should not be stored in plaintext in this file. + * If you want to mitigate this, please install ep_hash_auth and + * follow the section "secure your installation" in README.md + */ + + + "users": { + "admin": { + // 1) "password" can be replaced with "hash" if you install ep_hash_auth + // 2) please note that if password is null, the user will not be created + "password": "changeme1", + "is_admin": true + }, + "user": { + // 1) "password" can be replaced with "hash" if you install ep_hash_auth + // 2) please note that if password is null, the user will not be created + "password": "changeme1", + "is_admin": false + } + }, + + + /* + * Restrict socket.io transport methods + */ + "socketTransportProtocols" : ["websocket", "polling"], + + "socketIo": { + /* + * Maximum permitted client message size (in bytes). All messages from + * clients that are larger than this will be rejected. Large values make it + * possible to paste large amounts of text, and plugins may require a larger + * value to work properly, but increasing the value increases susceptibility + * to denial of service attacks (malicious clients can exhaust memory). + */ + "maxHttpBufferSize": 1000000 + }, + + /* + * Allow Load Testing tools to hit the Etherpad Instance. + * + * WARNING: this will disable security on the instance. + */ + "loadTest": false, + + /** + * Disable dump of objects preventing a clean exit + */ + "dumpOnUncleanExit": false, + + /* + * Disable indentation on new line when previous line ends with some special + * chars (':', '[', '(', '{') + */ + + /* + "indentationOnNewLine": false, + */ + + /* + * From Etherpad 1.8.3 onwards, import and export of pads is always rate + * limited. + * + * The default is to allow at most 10 requests per IP in a 90 seconds window. + * After that the import/export request is rejected. + * + * See https://github.com/nfriedly/express-rate-limit for more options + */ + "importExportRateLimiting": { + // duration of the rate limit window (milliseconds) + "windowMs": 90000, + + // maximum number of requests per IP to allow during the rate limit window + "max": 10 + }, + + /* + * From Etherpad 1.8.3 onwards, the maximum allowed size for a single imported + * file is always bounded. + * + * File size is specified in bytes. Default is 50 MB. + */ + "importMaxFileSize": 52428800, // 50 * 1024 * 1024 + + /* + * From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited + * + * The default is to allow at most 10 changes per IP in a 1 second window. + * After that the change is rejected. + * + * See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options + */ + "commitRateLimiting": { + // duration of the rate limit window (seconds) + "duration": 1, + + // maximum number of changes per IP to allow during the rate limit window + "points": 10 + }, + + /* + * Toolbar buttons configuration. + * + * Uncomment to customize. + */ + + /* + "toolbar": { + "left": [ + ["bold", "italic", "underline", "strikethrough"], + ["orderedlist", "unorderedlist", "indent", "outdent"], + ["undo", "redo"], + ["clearauthorship"] + ], + "right": [ + ["importexport", "timeslider", "savedrevision"], + ["settings", "embed"], + ["showusers"] + ], + "timeslider": [ + ["timeslider_export", "timeslider_returnToPad"] + ] + }, + */ + + /* + * Expose Etherpad version in the web interface and in the Server http header. + * + * Do not enable on production machines. + */ + "exposeVersion": false, + + /* + * The log level we are using. + * + * Valid values: DEBUG, INFO, WARN, ERROR + */ + "loglevel": "INFO", + + /* Override any strings found in locale directories */ + "customLocaleStrings": {}, + + /* Disable Admin UI tests */ + "enableAdminUITests": true, + + /* + * Enable/Disable case-insensitive pad names. + */ + "lowerCasePadIds": false +} diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 000000000..a42ef0188 --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,18 @@ +{ + "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/web.config b/src/web.config index bd50a60c5..65f2cf03f 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 7e9264ee3..bf8f1b23d 100644 --- a/start.bat +++ b/start.bat @@ -8,4 +8,5 @@ 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. -node node_modules\ep_etherpad-lite\node\server.js +cd src +pnpm run prod