mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-05-05 04:06:37 +02:00
Merge branch 'develop'
This commit is contained in:
commit
f1000e20fc
146
.github/workflows/backend-tests.yml
vendored
146
.github/workflows/backend-tests.yml
vendored
@ -28,27 +28,28 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# PRs: test on latest Node only. Push to develop: full matrix.
|
||||
node: ${{ github.event_name == 'pull_request' && fromJSON('[">=24.0.0 <25.0.0"]') || fromJSON('[">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]') }}
|
||||
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_HOME }}
|
||||
~/.local/share/gnpm
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
-
|
||||
name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
@ -57,19 +58,19 @@ jobs:
|
||||
version: 1.0
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm i --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm i --frozen-lockfile
|
||||
- name: Install admin ui
|
||||
working-directory: admin
|
||||
run: gnpm install --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm install
|
||||
- name: Build admin ui
|
||||
working-directory: admin
|
||||
run: gnpm build --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm build
|
||||
-
|
||||
name: Run the backend tests
|
||||
run: gnpm test --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm test
|
||||
- name: Run the new vitest tests
|
||||
working-directory: src
|
||||
run: gnpm run test:vitest --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm run test:vitest
|
||||
|
||||
withpluginsLinux:
|
||||
env:
|
||||
@ -84,27 +85,28 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ${{ github.event_name == 'pull_request' && fromJSON('[">=24.0.0 <25.0.0"]') || fromJSON('[">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]') }}
|
||||
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup pnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_HOME }}
|
||||
~/.local/share/gnpm
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
-
|
||||
name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
@ -113,14 +115,14 @@ jobs:
|
||||
version: 1.0
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build admin ui
|
||||
working-directory: admin
|
||||
run: gnpm build --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm build
|
||||
-
|
||||
name: Install Etherpad plugins
|
||||
run: >
|
||||
gnpm install --workspace-root
|
||||
pnpm add -w
|
||||
ep_align
|
||||
ep_author_hover
|
||||
ep_cursortrace
|
||||
@ -134,10 +136,10 @@ jobs:
|
||||
ep_table_of_contents
|
||||
-
|
||||
name: Run the backend tests
|
||||
run: gnpm test --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm test
|
||||
- name: Run the new vitest tests
|
||||
working-directory: src
|
||||
run: gnpm run test:vitest --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm run test:vitest
|
||||
|
||||
# Windows tests only run on push to develop/master, not on PRs
|
||||
withoutpluginsWindows:
|
||||
@ -148,7 +150,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]
|
||||
node: [20, 22, 24]
|
||||
name: Windows without plugins
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
@ -156,26 +158,28 @@ jobs:
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup pnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_HOME }}
|
||||
C:\gnpm\
|
||||
C:\Users\runneradmin\AppData\Roaming\gnpm\
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build admin ui
|
||||
working-directory: admin
|
||||
run: gnpm build --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm build
|
||||
-
|
||||
name: Fix up the settings.json
|
||||
run: |
|
||||
@ -184,10 +188,10 @@ jobs:
|
||||
-
|
||||
name: Run the backend tests
|
||||
working-directory: src
|
||||
run: gnpm test --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm test
|
||||
- name: Run the new vitest tests
|
||||
working-directory: src
|
||||
run: gnpm run test:vitest --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm run test:vitest
|
||||
|
||||
withpluginsWindows:
|
||||
env:
|
||||
@ -197,7 +201,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]
|
||||
node: [20, 22, 24]
|
||||
name: Windows with Plugins
|
||||
runs-on: windows-latest
|
||||
|
||||
@ -206,29 +210,31 @@ jobs:
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup pnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_HOME }}
|
||||
C:\gnpm\
|
||||
C:\Users\runneradmin\AppData\Roaming\gnpm\
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
- name: Install dependencies
|
||||
run: gnpm install --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm install
|
||||
- name: Build admin ui
|
||||
working-directory: admin
|
||||
run: gnpm build --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm build
|
||||
-
|
||||
name: Install Etherpad plugins
|
||||
run: >
|
||||
gnpm install --workspace-root
|
||||
pnpm add -w
|
||||
ep_align
|
||||
ep_author_hover
|
||||
ep_cursortrace
|
||||
@ -251,7 +257,7 @@ jobs:
|
||||
# rules.
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm install --frozen-lockfile
|
||||
-
|
||||
name: Fix up the settings.json
|
||||
run: |
|
||||
@ -260,7 +266,7 @@ jobs:
|
||||
-
|
||||
name: Run the backend tests
|
||||
working-directory: src
|
||||
run: gnpm test --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm test
|
||||
- name: Run the new vitest tests
|
||||
working-directory: src
|
||||
run: gnpm run test:vitest --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm run test:vitest
|
||||
|
||||
31
.github/workflows/build-and-deploy-docs.yml
vendored
31
.github/workflows/build-and-deploy-docs.yml
vendored
@ -34,28 +34,31 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
name: Cache vitepress build
|
||||
with:
|
||||
version: 0.0.12
|
||||
path: doc/.vitepress/cache
|
||||
key: ${{ runner.os }}-vitepress-${{ hashFiles('doc/**/*.md', 'doc/.vitepress/config.*') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-vitepress-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Install dependencies
|
||||
run: gnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build app
|
||||
working-directory: doc
|
||||
run: gnpm run docs:build
|
||||
run: pnpm run docs:build
|
||||
env:
|
||||
COMMIT_REF: ${{ github.sha }}
|
||||
- name: Upload artifact
|
||||
|
||||
22
.github/workflows/docker.yml
vendored
22
.github/workflows/docker.yml
vendored
@ -43,21 +43,17 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_HOME }}
|
||||
~/.local/share/gnpm
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
-
|
||||
name: Test
|
||||
working-directory: etherpad
|
||||
@ -74,7 +70,7 @@ jobs:
|
||||
*) printf %s\\n "unexpected status: ${status}" >&2; exit 1;;
|
||||
esac
|
||||
done
|
||||
(cd src && gnpm run test-container)
|
||||
(cd src && pnpm run test-container)
|
||||
git clean -dxf .
|
||||
|
||||
build-test-db-drivers:
|
||||
|
||||
33
.github/workflows/frontend-admin-tests.yml
vendored
33
.github/workflows/frontend-admin-tests.yml
vendored
@ -29,24 +29,25 @@ jobs:
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_HOME }}
|
||||
~/.local/share/gnpm
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm i --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm i
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
id: playwright-cache
|
||||
@ -71,11 +72,11 @@ jobs:
|
||||
- name: Build admin frontend
|
||||
working-directory: admin
|
||||
run: |
|
||||
gnpm run build --runtimeVersion="${{ matrix.node }}"
|
||||
pnpm run build
|
||||
- name: Run the frontend admin tests
|
||||
shell: bash
|
||||
run: |
|
||||
gnpm run prod --runtimeVersion="${{ matrix.node }}" > /tmp/etherpad-server.log 2>&1 &
|
||||
pnpm run prod > /tmp/etherpad-server.log 2>&1 &
|
||||
connected=false
|
||||
can_connect() {
|
||||
curl -sSfo /dev/null http://localhost:9001/ || return 1
|
||||
@ -87,7 +88,7 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
cd src
|
||||
gnpm run test-admin --runtimeVersion="${{ matrix.node }}"
|
||||
pnpm run test-admin
|
||||
- name: Upload server log on failure
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
|
||||
72
.github/workflows/frontend-tests.yml
vendored
72
.github/workflows/frontend-tests.yml
vendored
@ -22,32 +22,34 @@ jobs:
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_HOME }}
|
||||
~/.cache/ms-playwright
|
||||
~/.local/share/gnpm
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
version: 0.0.12
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('src/package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
-
|
||||
name: Create settings.json
|
||||
run: cp ./src/tests/settings.json settings.json
|
||||
- name: Run the frontend tests
|
||||
shell: bash
|
||||
run: |
|
||||
gnpm run prod > /tmp/etherpad-server.log 2>&1 &
|
||||
pnpm run prod > /tmp/etherpad-server.log 2>&1 &
|
||||
connected=false
|
||||
can_connect() {
|
||||
curl -sSfo /dev/null http://localhost:9001/ || return 1
|
||||
@ -59,8 +61,8 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
cd src
|
||||
gnpm exec playwright install chromium --with-deps
|
||||
gnpm run test-ui --project=chromium
|
||||
pnpm exec playwright install chromium --with-deps
|
||||
pnpm run test-ui --project=chromium
|
||||
- name: Upload server log on failure
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
@ -83,30 +85,32 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_HOME }}
|
||||
~/.local/share/gnpm
|
||||
~/.cache/ms-playwright
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
version: 0.0.12
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('src/package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Create settings.json
|
||||
run: cp ./src/tests/settings.json settings.json
|
||||
- name: Run the frontend tests
|
||||
shell: bash
|
||||
run: |
|
||||
gnpm run prod > /tmp/etherpad-server.log 2>&1 &
|
||||
pnpm run prod > /tmp/etherpad-server.log 2>&1 &
|
||||
connected=false
|
||||
can_connect() {
|
||||
curl -sSfo /dev/null http://localhost:9001/ || return 1
|
||||
@ -118,8 +122,8 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
cd src
|
||||
gnpm exec playwright install firefox --with-deps
|
||||
gnpm run test-ui --project=firefox
|
||||
pnpm exec playwright install firefox --with-deps
|
||||
pnpm run test-ui --project=firefox
|
||||
- name: Upload server log on failure
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
|
||||
32
.github/workflows/handleRelease.yml
vendored
32
.github/workflows/handleRelease.yml
vendored
@ -29,30 +29,28 @@ jobs:
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
~/.cache/ms-playwright
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build etherpad
|
||||
run: gnpm run build:etherpad
|
||||
# On release, create release
|
||||
run: pnpm run build:etherpad
|
||||
# On release, create release. `--silent` suppresses pnpm's lifecycle
|
||||
# banner ("> generateChangelog\n> node ...") that would otherwise be
|
||||
# captured into CHANGELOG.txt and end up at the top of the GitHub
|
||||
# release notes.
|
||||
- name: Generate Changelog
|
||||
working-directory: bin
|
||||
run: gnpm run generateChangelog ${{ github.ref }} > ${{ github.workspace }}-CHANGELOG.txt
|
||||
run: pnpm --silent run generateChangelog ${{ github.ref }} > ${{ github.workspace }}-CHANGELOG.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: ${{startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
89
.github/workflows/load-test.yml
vendored
89
.github/workflows/load-test.yml
vendored
@ -26,35 +26,26 @@ jobs:
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
~/.cache/ms-playwright
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
-
|
||||
name: Install etherpad-load-test
|
||||
run: sudo npm install -g etherpad-load-test-socket-io
|
||||
-
|
||||
name: Run load test
|
||||
run: |
|
||||
gnpm --gnpmEnv
|
||||
eval "$(gnpm --gnpmEnv)"
|
||||
echo $PATH
|
||||
src/tests/frontend/travis/runnerLoadTest.sh 25 50
|
||||
run: src/tests/frontend/travis/runnerLoadTest.sh 25 50
|
||||
|
||||
withplugins:
|
||||
# run on pushes to any branch
|
||||
@ -69,31 +60,24 @@ jobs:
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
~/.cache/ms-playwright
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
-
|
||||
name: Install etherpad-load-test
|
||||
run: sudo npm 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: >
|
||||
gnpm install --workspace-root
|
||||
pnpm add -w
|
||||
ep_align
|
||||
ep_author_hover
|
||||
ep_cursortrace
|
||||
@ -117,12 +101,10 @@ jobs:
|
||||
# rules.
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
-
|
||||
name: Run load test
|
||||
run: |
|
||||
eval "$(gnpm --gnpmEnv)"
|
||||
src/tests/frontend/travis/runnerLoadTest.sh 25 50
|
||||
run: src/tests/frontend/travis/runnerLoadTest.sh 25 50
|
||||
|
||||
long:
|
||||
# run on pushes to any branch
|
||||
@ -137,32 +119,23 @@ jobs:
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
~/.cache/ms-playwright
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
-
|
||||
name: Install etherpad-load-test
|
||||
run: sudo npm install -g etherpad-load-test-socket-io
|
||||
-
|
||||
name: Run load test
|
||||
run: |
|
||||
gnpm --gnpmEnv
|
||||
eval "$(gnpm --gnpmEnv)"
|
||||
echo $PATH
|
||||
src/tests/frontend/travis/runnerLoadTest.sh 5000 5
|
||||
run: src/tests/frontend/travis/runnerLoadTest.sh 5000 5
|
||||
|
||||
28
.github/workflows/perform-type-check.yml
vendored
28
.github/workflows/perform-type-check.yml
vendored
@ -26,25 +26,19 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
~/.cache/ms-playwright
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Perform type check
|
||||
working-directory: ./src
|
||||
run: gnpm run ts-check
|
||||
run: pnpm run ts-check
|
||||
|
||||
23
.github/workflows/rate-limit.yml
vendored
23
.github/workflows/rate-limit.yml
vendored
@ -29,22 +29,17 @@ jobs:
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
~/.cache/ms-playwright
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
|
||||
-
|
||||
name: docker network
|
||||
@ -63,7 +58,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: gnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
-
|
||||
name: run rate limit test
|
||||
run: |
|
||||
|
||||
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
@ -48,24 +48,19 @@ jobs:
|
||||
path: ether.github.com
|
||||
token: '${{ secrets.ETHER_RELEASE_TOKEN }}'
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
~/.cache/ms-playwright
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Install dependencies ether.github.com
|
||||
run: gnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: ether.github.com
|
||||
- name: Set git user
|
||||
run: |
|
||||
@ -82,8 +77,8 @@ jobs:
|
||||
working-directory: etherpad
|
||||
run: |
|
||||
cd bin
|
||||
gnpm install
|
||||
gnpm run release ${{ inputs.release_type }}
|
||||
pnpm install
|
||||
pnpm run release ${{ inputs.release_type }}
|
||||
- name: Push after release
|
||||
working-directory: etherpad
|
||||
run: |
|
||||
|
||||
30
.github/workflows/releaseEtherpad.yml
vendored
30
.github/workflows/releaseEtherpad.yml
vendored
@ -23,33 +23,25 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org/
|
||||
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
||||
run: npm install -g npm@latest
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
- uses: actions/cache@v5
|
||||
name: Setup pnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Install dependencies
|
||||
run: gnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Rename etherpad
|
||||
working-directory: ./src
|
||||
run: sed -i 's/ep_etherpad-lite/ep_etherpad/g' package.json
|
||||
# Use `npm publish` directly (not `gnpm`/`pnpm` wrappers) because OIDC
|
||||
# trusted publishing requires npm CLI >= 11.5.1 and the wrappers shell
|
||||
# Use `npm publish` directly (not the `pnpm` wrapper) because OIDC
|
||||
# trusted publishing requires npm CLI >= 11.5.1 and the wrapper shells
|
||||
# out to npm; calling npm directly avoids any shim ambiguity. The
|
||||
# ep_etherpad package must have a trusted publisher configured on
|
||||
# npmjs.com pointing at this workflow file. See:
|
||||
|
||||
2
.github/workflows/update-plugins.yml
vendored
2
.github/workflows/update-plugins.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
|
||||
@ -36,22 +36,22 @@ jobs:
|
||||
with:
|
||||
ref: develop #FIXME change to master when doing release
|
||||
- uses: actions/cache@v5
|
||||
name: Setup gnpm cache
|
||||
if: always()
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
~/.local/share/gnpm
|
||||
~/.cache/ms-playwright
|
||||
/usr/local/bin/gnpm
|
||||
/usr/local/bin/gnpm-0.0.12
|
||||
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gnpm-store-
|
||||
- name: Setup gnpm
|
||||
uses: SamTV12345/gnpm-setup@main
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 0.0.12
|
||||
version: 10.33.2
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
- name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
with:
|
||||
@ -59,14 +59,14 @@ jobs:
|
||||
version: 1.0
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build admin ui
|
||||
working-directory: admin
|
||||
run: gnpm build --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm build
|
||||
-
|
||||
name: Install Etherpad plugins
|
||||
run: >
|
||||
gnpm run install-plugins
|
||||
pnpm run install-plugins
|
||||
ep_align
|
||||
ep_author_hover
|
||||
ep_cursortrace
|
||||
@ -78,13 +78,13 @@ jobs:
|
||||
ep_set_title_on_pad
|
||||
ep_spellcheck
|
||||
ep_subscript_and_superscript
|
||||
ep_table_of_contents --runtimeVersion="${{ matrix.node }}"
|
||||
ep_table_of_contents
|
||||
-
|
||||
name: Run the backend tests
|
||||
run: gnpm run test --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm run test
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm install --frozen-lockfile
|
||||
# Because actions/checkout@v6 is called with "ref: master" and without
|
||||
# "fetch-depth: 0", the local clone does not have the ${GITHUB_SHA}
|
||||
# commit. Fetch ${GITHUB_REF} to get the ${GITHUB_SHA} commit. Note that a
|
||||
@ -101,4 +101,4 @@ jobs:
|
||||
# commit that merges the PR's source branch to its destination branch.
|
||||
run: git checkout "${GITHUB_SHA}"
|
||||
- name: Run the backend tests
|
||||
run: gnpm run test --runtimeVersion="${{ matrix.node }}"
|
||||
run: pnpm run test
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@ -1,3 +1,15 @@
|
||||
# 2.7.2
|
||||
|
||||
### Notable enhancements and fixes
|
||||
|
||||
- Accessibility pass: corrected dialog semantics, improved focus management, added missing icon labels, and set the `html lang` attribute correctly.
|
||||
- Chat: clicking the chat icon works again, disabled toggles render properly, and the username layout no longer overflows.
|
||||
- `/export/etherpad` now honors the `:rev` URL segment, so revision-specific exports return the requested revision instead of the latest.
|
||||
- Undo / redo now scrolls the viewport to follow the caret, so reverted edits stay in view.
|
||||
- Page Down / Page Up now scrolls by viewport height instead of a fixed line count, matching standard editor behavior on tall and short windows alike.
|
||||
- Editbar: caret is restored to the pad after changing a toolbar select, so typing continues in the document instead of falling through to the toolbar.
|
||||
- Admin: i18n is restored on `/admin` so the admin UI is translated again.
|
||||
|
||||
# 2.7.1
|
||||
|
||||
### Notable enhancements and fixes
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -25,9 +25,9 @@
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next": "^26.0.7",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.73.1",
|
||||
@ -37,7 +37,6 @@
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "npm:rolldown-vite@7.2.10",
|
||||
"vite-plugin-babel": "^1.6.0",
|
||||
"vite-plugin-static-copy": "^4.1.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@ -1,36 +1,47 @@
|
||||
import i18n from 'i18next'
|
||||
import {initReactI18next} from "react-i18next";
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import type {BackendModule} from 'i18next';
|
||||
|
||||
// Core translations live in /src/locales (shared with the pad UI). Letting
|
||||
// Vite resolve them via import.meta.glob means each language ships as its own
|
||||
// hashed JSON chunk, lazy-loaded on demand — no build-time copy step or
|
||||
// /admin/locales/* express route. Earlier setups copying files into the build
|
||||
// output were fragile (see https://github.com/ether/etherpad/issues/7586).
|
||||
const coreLocales = import.meta.glob<{default: Record<string, unknown>}>(
|
||||
'../../../src/locales/*.json');
|
||||
|
||||
import { BackendModule } from 'i18next';
|
||||
const coreLocaleByLang = (language: string) =>
|
||||
coreLocales[`../../../src/locales/${language}.json`];
|
||||
|
||||
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)
|
||||
let json;
|
||||
|
||||
try {
|
||||
json = JSON.parse(await localeJSON.text())
|
||||
} catch(e) {
|
||||
callback(new Error("Error loading"), null);
|
||||
if (namespace === 'translation') {
|
||||
const loader = coreLocaleByLang(language);
|
||||
if (!loader) {
|
||||
callback(new Error(`No core locale for "${language}"`), null);
|
||||
return;
|
||||
}
|
||||
const mod = await loader();
|
||||
callback(null, mod.default);
|
||||
return;
|
||||
}
|
||||
// Plugin namespaces (e.g. ep_admin_pads) are still served as static
|
||||
// assets from admin/public/<namespace>/<lang>.json.
|
||||
const baseURL = `${import.meta.env.BASE_URL}/${namespace}/${language}.json`;
|
||||
const res = await fetch(baseURL);
|
||||
if (!res.ok) {
|
||||
callback(new Error(`HTTP ${res.status} loading ${baseURL}`), null);
|
||||
return;
|
||||
}
|
||||
callback(null, await res.json());
|
||||
} catch (e) {
|
||||
callback(e instanceof Error ? e : new Error(String(e)), null);
|
||||
}
|
||||
|
||||
|
||||
callback(null, json);
|
||||
},
|
||||
|
||||
save: function () {
|
||||
|
||||
@ -1,36 +1,31 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import {viteStaticCopy} from "vite-plugin-static-copy";
|
||||
import react from '@vitejs/plugin-react';
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: '../src/locales',
|
||||
dest: ''
|
||||
}
|
||||
]
|
||||
}), react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
}})],
|
||||
base: '/admin',
|
||||
build:{
|
||||
outDir: '../src/templates/admin',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server:{
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
base: '/admin',
|
||||
build: {
|
||||
outDir: '../src/templates/admin',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/socket.io/*': {
|
||||
target: 'http://localhost:9001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/admin-auth/': {
|
||||
target: 'http://localhost:9001',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
'/admin-auth/': {
|
||||
target: 'http://localhost:9001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.2",
|
||||
"description": "",
|
||||
"main": "checkAllPads.js",
|
||||
"directories": {
|
||||
|
||||
404
docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md
Normal file
404
docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md
Normal file
@ -0,0 +1,404 @@
|
||||
# Accessibility: Dialog semantics, icon labels, html lang
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add ARIA dialog semantics, focus management, accessible names for icon-only controls, and a `lang` attribute — addressing the highest-impact items from the 2026-04-22 a11y audit.
|
||||
|
||||
**Architecture:** All changes live in templates + a small set of TS files. No new modules. The existing `toggleDropDown` in `pad_editbar.ts` is the single chokepoint for popup show/hide; we extend it with focus management. Icon-only buttons get accessible names via a new `icon.*` locale namespace consumed via `data-l10n-id` (existing l10n machinery applies to `aria-label` automatically through html10n's attribute syntax).
|
||||
|
||||
**Tech Stack:** EJS templates, TypeScript, jQuery (legacy), Playwright tests.
|
||||
|
||||
**Out of scope:** WCAG-AA contrast pass, touch-target sizing (28→44px), full focus-visible CSS pass, modal-by-modal focus-trap library swap. Leaving those for follow-up PRs to keep this one reviewable.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `lang` attribute to top-level templates
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/templates/pad.html:7`
|
||||
- Modify: `src/templates/index.html` (top `<html>` tag)
|
||||
- Modify: `src/templates/timeslider.html` (top `<html>` tag)
|
||||
|
||||
The pad templates render server-side; `clientVars.userAgent` and `req.headers['accept-language']` aren't directly available here, but the rendered locale is exposed via `settings.defaultLang` in `Settings.ts`. Use that, defaulting to `en` if unset.
|
||||
|
||||
- [ ] **Step 1.1:** Edit `src/templates/pad.html` line 7. Replace
|
||||
```html
|
||||
<html translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
|
||||
```
|
||||
with
|
||||
```html
|
||||
<html lang="<%=settings.defaultLang || 'en'%>" translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2:** Apply the same `lang` attribute to `src/templates/index.html` and `src/templates/timeslider.html` `<html>` tags. (Read each first to get exact current line.)
|
||||
|
||||
- [ ] **Step 1.3:** The client-side language switcher (`html10n`) already updates `documentElement.lang` after page load — verify by grepping `pad_utils.ts` and `vendors/html10n.ts` for `lang =`. No code change needed if html10n already does this; otherwise add one line in `pad.ts` after l10n loads to set `document.documentElement.lang` from the active locale.
|
||||
|
||||
- [ ] **Step 1.4:** Commit:
|
||||
```bash
|
||||
git add src/templates/pad.html src/templates/index.html src/templates/timeslider.html
|
||||
git commit -m "fix(a11y): add lang attribute to top-level templates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Dialog semantics on popups
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/templates/pad.html` — popups at lines 117 (`#settings`), 190 (`#import_export`), 242 (`#connectivity`), 325 (`#embed`), 349 (`#users`), 353 (`#mycolorpicker`), 410 (`#skin-variants`).
|
||||
|
||||
For each popup, add `role="dialog"`, `aria-modal="true"`, `aria-labelledby="<h1-id>"`. Where the popup has an `<h1>` without an id, add an id. Connectivity has multiple `<h1>` (one per state) — give that one `role="dialog" aria-modal="true" aria-label="Connection status"` instead of labelledby.
|
||||
|
||||
- [ ] **Step 2.1:** Settings popup. Add id to its `<h1>`:
|
||||
```html
|
||||
<h1 id="settings-title" data-l10n-id="pad.settings.padSettings"></h1>
|
||||
```
|
||||
And:
|
||||
```html
|
||||
<div id="settings" class="popup" role="dialog" aria-modal="true" aria-labelledby="settings-title" hidden><div class="popup-content">
|
||||
```
|
||||
Note: add `hidden` so the dialog is not announced to screen readers when closed. The existing `.popup-show` class already controls visibility via CSS; we'll toggle the `hidden` attribute alongside it in Task 3.
|
||||
|
||||
- [ ] **Step 2.2:** Import/export popup — add id `importexport-title` to its `<h1>`, add same dialog attrs.
|
||||
|
||||
- [ ] **Step 2.3:** Connectivity popup — `aria-label="Connection status"` (no labelledby; label is generic since the h1 changes per state).
|
||||
|
||||
- [ ] **Step 2.4:** Embed popup — id `embed-title` on the `<h1>`, dialog attrs.
|
||||
|
||||
- [ ] **Step 2.5:** Users popup — `aria-label="Users on this pad"` (no `<h1>` in the markup).
|
||||
|
||||
- [ ] **Step 2.6:** Mycolorpicker — `aria-label="Choose your author color"`.
|
||||
|
||||
- [ ] **Step 2.7:** Skin-variants popup — id `skin-variants-title` on its `<h1>`, dialog attrs.
|
||||
|
||||
- [ ] **Step 2.8:** Fix the `aria-role="document"` typo on `#otherusers` (pad.html:366) → replace with `role="region" aria-live="polite" aria-label="Active users on this pad"`. (`aria-role` is not a real attribute — it's `role`.)
|
||||
|
||||
- [ ] **Step 2.9:** Commit:
|
||||
```bash
|
||||
git add src/templates/pad.html
|
||||
git commit -m "fix(a11y): dialog semantics on popups; fix aria-role typo on userlist"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Focus management in `toggleDropDown`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/static/js/pad_editbar.ts:209-256` (the `toggleDropDown` method)
|
||||
|
||||
When opening a popup: remember the trigger element, move focus to the first focusable element inside the popup, set `hidden=false`. When closing: set `hidden=true`, restore focus to the trigger. Add an Escape handler that closes any open popup.
|
||||
|
||||
- [ ] **Step 3.1:** At the top of the `padeditbar` class (find the existing field declarations near the constructor), add:
|
||||
```ts
|
||||
private lastTrigger: HTMLElement | null = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2:** Replace the body of `toggleDropDown(moduleName, cb = null)` to:
|
||||
- Remember `document.activeElement` as `lastTrigger` when transitioning from no-popup to popup-open.
|
||||
- After applying classes, for each module that became visible, set `module.attr('hidden', null)`; for each that became hidden, set `module.attr('hidden', '')`.
|
||||
- When transitioning to "all closed" (moduleName === 'none' or all modules ended up hidden), and `lastTrigger` is set and is still in the DOM, call `lastTrigger.focus()` then clear `lastTrigger`.
|
||||
- When opening, after a `requestAnimationFrame`, focus the first focusable inside the now-visible popup (`module.find('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])').filter(':visible').first().trigger('focus')`).
|
||||
|
||||
Show full code:
|
||||
```ts
|
||||
toggleDropDown(moduleName, cb = null) {
|
||||
let cbErr = null;
|
||||
try {
|
||||
if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) return;
|
||||
|
||||
$('.nice-select').removeClass('open');
|
||||
$('.toolbar-popup').removeClass('popup-show');
|
||||
|
||||
const wasAnyOpen = $('.popup.popup-show').length > 0;
|
||||
if (!wasAnyOpen && moduleName !== 'none') {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (active && active !== document.body) this.lastTrigger = active;
|
||||
}
|
||||
|
||||
let openedModule: JQuery<HTMLElement> | null = null;
|
||||
|
||||
if (moduleName === 'none') {
|
||||
for (const thisModuleName of this.dropdowns) {
|
||||
if (thisModuleName === 'users') continue;
|
||||
const module = $(`#${thisModuleName}`);
|
||||
const isAForceReconnectMessage = module.find('button#forcereconnect:visible').length > 0;
|
||||
if (isAForceReconnectMessage) continue;
|
||||
if (module.hasClass('popup-show')) {
|
||||
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
|
||||
module.removeClass('popup-show');
|
||||
module.attr('hidden', '');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const thisModuleName of this.dropdowns) {
|
||||
const module = $(`#${thisModuleName}`);
|
||||
if (module.hasClass('popup-show')) {
|
||||
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
|
||||
module.removeClass('popup-show');
|
||||
module.attr('hidden', '');
|
||||
} else if (thisModuleName === moduleName) {
|
||||
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
|
||||
module.addClass('popup-show');
|
||||
module.removeAttr('hidden');
|
||||
openedModule = module;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openedModule) {
|
||||
const target = openedModule;
|
||||
requestAnimationFrame(() => {
|
||||
const focusable = target.find(
|
||||
'button:visible, a[href]:visible, input:visible, select:visible, textarea:visible, [tabindex]:not([tabindex="-1"]):visible'
|
||||
).first();
|
||||
if (focusable.length) (focusable[0] as HTMLElement).focus();
|
||||
});
|
||||
} else if ($('.popup.popup-show').length === 0 && this.lastTrigger) {
|
||||
const trigger = this.lastTrigger;
|
||||
this.lastTrigger = null;
|
||||
if (document.body.contains(trigger)) trigger.focus();
|
||||
}
|
||||
} catch (err) {
|
||||
cbErr = err || new Error(err);
|
||||
} finally {
|
||||
if (cb) Promise.resolve().then(() => cb(cbErr));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.3:** Add a global keydown handler. Find the existing `init()` method (or wherever document-level handlers are bound — likely in `padeditbar.init` which is called from `pad.ts`). At the end of `init()`, add:
|
||||
```ts
|
||||
$(document).on('keydown', (e) => {
|
||||
if (e.key === 'Escape' && $('.popup.popup-show').length > 0) {
|
||||
this.toggleDropDown('none');
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3.4:** Run tsc to confirm types compile:
|
||||
```bash
|
||||
pnpm --dir src run ts-check
|
||||
```
|
||||
Expected: no new errors in `pad_editbar.ts`.
|
||||
|
||||
- [ ] **Step 3.5:** Commit:
|
||||
```bash
|
||||
git add src/static/js/pad_editbar.ts
|
||||
git commit -m "fix(a11y): focus management and Escape-to-close for popups"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Make chat icon a real button + label its close/stick controls
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/templates/pad.html:380` (`#chaticon` div → button)
|
||||
- Modify: `src/templates/pad.html:390-391` (`#titlecross`, `#titlesticky` anchors → buttons)
|
||||
- Modify: `src/static/js/chat.ts` if any code reads `#chaticon` as a div (grep first)
|
||||
|
||||
- [ ] **Step 4.1:** Grep for `chaticon` references in JS/CSS so we don't break selectors:
|
||||
```bash
|
||||
grep -rn "chaticon" src/static/js src/static/css src/static/skins
|
||||
```
|
||||
Expected: CSS targets `#chaticon`; JS reads `.click()` / `.show()`. None of these care whether it's a div or a button.
|
||||
|
||||
- [ ] **Step 4.2:** Replace the chat icon block with:
|
||||
```html
|
||||
<button type="button" id="chaticon" class="visible" title="Chat (Alt C)" aria-label="Open chat" data-l10n-id="pad.chat.title">
|
||||
<span id="chatlabel" data-l10n-id="pad.chat"></span>
|
||||
<span class="buttonicon buttonicon-chat" aria-hidden="true"></span>
|
||||
<span id="chatcounter" aria-label="Unread messages">0</span>
|
||||
</button>
|
||||
```
|
||||
Move the `onclick="chat.show();return false;"` to a JS handler in `chat.ts` `init()` (find existing init):
|
||||
```ts
|
||||
$('#chaticon').on('click', (e) => { e.preventDefault(); chat.show(); });
|
||||
```
|
||||
(If `chat.show()` is already wired by another listener, just remove the inline `onclick` and rely on the existing handler — confirm by greping.)
|
||||
|
||||
- [ ] **Step 4.3:** Replace chat header close/stick anchors:
|
||||
```html
|
||||
<button type="button" id="titlecross" class="hide-reduce-btn" aria-label="Close chat">−</button>
|
||||
<button type="button" id="titlesticky" class="stick-to-screen-btn" aria-label="Pin chat to screen" data-l10n-id="pad.chat.stick.title">▮</button>
|
||||
```
|
||||
Move their inline `onClick` handlers to `chat.ts`:
|
||||
```ts
|
||||
$('#titlecross').on('click', (e) => { e.preventDefault(); chat.hide(); });
|
||||
$('#titlesticky').on('click', (e) => { e.preventDefault(); chat.stickToScreen(true); });
|
||||
```
|
||||
|
||||
- [ ] **Step 4.4:** Inspect CSS for `#chaticon` / `#titlecross` / `#titlesticky`. Buttons get default browser styling (border, padding) that may break the layout. Add a CSS reset block in `src/static/css/pad/chat.css` (or wherever those selectors already live):
|
||||
```css
|
||||
#chaticon, #titlecross, #titlesticky {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
#chaticon:focus-visible, #titlecross:focus-visible, #titlesticky:focus-visible {
|
||||
outline: 2px solid #0066cc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
Find the right file by grepping `#chaticon` in `src/static/css`.
|
||||
|
||||
- [ ] **Step 4.5:** ts-check:
|
||||
```bash
|
||||
pnpm --dir src run ts-check
|
||||
```
|
||||
|
||||
- [ ] **Step 4.6:** Commit:
|
||||
```bash
|
||||
git add src/templates/pad.html src/static/js/chat.ts src/static/css
|
||||
git commit -m "fix(a11y): make chaticon and chat header controls real buttons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add `icon.*` locale namespace and label icon-only controls
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/locales/en.json` — add new keys
|
||||
- Modify: `src/templates/pad.html` — apply `data-l10n-id` to `aria-label` on icon-only elements
|
||||
|
||||
html10n supports per-attribute translation via `key.attr` style — for `aria-label`, the convention used elsewhere in this codebase is `data-l10n-id="key"` plus a sibling key `key.aria-label`. Check existing usage by grepping `aria-label` in `src/locales/en.json`:
|
||||
|
||||
- [ ] **Step 5.1:** Grep current usage:
|
||||
```bash
|
||||
grep -n "aria-label\|.title" src/locales/en.json | head -20
|
||||
```
|
||||
Determine the convention. If html10n uses `{key}.aria-label`, follow that. Otherwise use plain `key` and apply via `aria-label` directly in HTML (no l10n on the aria-label) and accept English-only for now.
|
||||
|
||||
- [ ] **Step 5.2:** Add to `src/locales/en.json` after the `pad.chat.*` block:
|
||||
```json
|
||||
"pad.icon.export.etherpad": "Export as Etherpad",
|
||||
"pad.icon.export.html": "Export as HTML",
|
||||
"pad.icon.export.plain": "Export as plain text",
|
||||
"pad.icon.export.word": "Export as Microsoft Word",
|
||||
"pad.icon.export.pdf": "Export as PDF",
|
||||
"pad.icon.export.opendocument": "Export as ODF",
|
||||
"pad.icon.showmore": "Show more toolbar buttons",
|
||||
```
|
||||
(Insert with correct JSON commas.)
|
||||
|
||||
- [ ] **Step 5.3:** Apply to the export `<a>` elements in `src/templates/pad.html:215-232`:
|
||||
```html
|
||||
<a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad" data-l10n-id="pad.icon.export.etherpad">
|
||||
```
|
||||
Repeat per format. Add `aria-hidden="true"` to the inner `<span class="exporttype buttonicon ...">` since the link itself now carries the label.
|
||||
|
||||
- [ ] **Step 5.4:** Convert the show-more span to a button on `pad.html:74`:
|
||||
```html
|
||||
<button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" data-l10n-id="pad.icon.showmore"></button>
|
||||
```
|
||||
Verify CSS targeting `.show-more-icon-btn` doesn't depend on element type — grep first.
|
||||
|
||||
- [ ] **Step 5.5:** Theme switcher knob (`pad.html:172`) currently has `aria-label="theme-switcher-knob"` which is a CSS-class-style label, not human text. Change to `aria-label="Toggle theme"`.
|
||||
|
||||
- [ ] **Step 5.6:** Commit:
|
||||
```bash
|
||||
git add src/locales/en.json src/templates/pad.html
|
||||
git commit -m "fix(a11y): accessible names for icon-only buttons and links"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Playwright test for dialog semantics + Escape
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/frontend-new/specs/a11y_dialogs.spec.ts`
|
||||
|
||||
Cover the high-impact promises: settings popup opens with role=dialog, Escape closes it, focus returns to trigger.
|
||||
|
||||
- [ ] **Step 6.1:** Write the failing test:
|
||||
```ts
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {goToNewPad} from "../helper/padHelper";
|
||||
|
||||
test.beforeEach(async ({page}) => { await goToNewPad(page); });
|
||||
|
||||
test('settings popup has dialog semantics and Escape closes it', async ({page}) => {
|
||||
const settingsButton = page.locator('.buttonicon.buttonicon-cog');
|
||||
await settingsButton.click();
|
||||
|
||||
const dialog = page.locator('#settings');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
// Focus should return to the trigger
|
||||
const focused = await page.evaluate(() => document.activeElement?.className || '');
|
||||
expect(focused).toContain('buttonicon-cog');
|
||||
});
|
||||
|
||||
test('html element has lang attribute', async ({page}) => {
|
||||
const lang = await page.locator('html').getAttribute('lang');
|
||||
expect(lang).toBeTruthy();
|
||||
expect(lang!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('export links have accessible names', async ({page}) => {
|
||||
await page.locator('.buttonicon.buttonicon-import_export').click();
|
||||
const pdfLink = page.locator('#exportpdfa');
|
||||
const label = await pdfLink.getAttribute('aria-label');
|
||||
expect(label).toBeTruthy();
|
||||
});
|
||||
|
||||
test('chaticon is a button with accessible name', async ({page}) => {
|
||||
const chatIcon = page.locator('#chaticon');
|
||||
const tagName = await chatIcon.evaluate(el => el.tagName.toLowerCase());
|
||||
expect(tagName).toBe('button');
|
||||
const label = await chatIcon.getAttribute('aria-label');
|
||||
expect(label).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2:** Verify the Playwright spec runs (headless per project rule):
|
||||
```bash
|
||||
cd src && pnpm exec playwright test tests/frontend-new/specs/a11y_dialogs.spec.ts --reporter=list
|
||||
```
|
||||
Expected: all 4 tests pass.
|
||||
|
||||
- [ ] **Step 6.3:** Commit:
|
||||
```bash
|
||||
git add src/tests/frontend-new/specs/a11y_dialogs.spec.ts
|
||||
git commit -m "test(a11y): verify dialog semantics, html lang, export labels, chat button"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run the full local checks before push
|
||||
|
||||
- [ ] **Step 7.1:** ts-check from `src/`:
|
||||
```bash
|
||||
pnpm --dir src run ts-check
|
||||
```
|
||||
|
||||
- [ ] **Step 7.2:** Backend tests:
|
||||
```bash
|
||||
pnpm --dir src run test:backend
|
||||
```
|
||||
|
||||
- [ ] **Step 7.3:** Push and open a PR against `johnmclear/etherpad-lite`:
|
||||
```bash
|
||||
git push -u fork fix/a11y-dialogs-labels-lang
|
||||
gh pr create --repo johnmclear/etherpad-lite --base develop --head fix/a11y-dialogs-labels-lang \
|
||||
--title "fix(a11y): dialog semantics, icon labels, html lang" \
|
||||
--body "..."
|
||||
```
|
||||
|
||||
- [ ] **Step 7.4:** Post `/review` comment on the PR to trigger Qodo.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** Original audit's high-impact items were (a) dialog semantics + focus trap, (b) aria-labels via icon.* namespace, (c) html lang. All three covered (Tasks 2+3, 4+5, 1). Bonus: aria-role typo on userlist (Task 2.8) and chat header buttons (Task 4.3).
|
||||
- **Out of scope, deliberately:** focus-visible CSS sweep, contrast pass, touch-target sizing, full focus-trap library (we do simple init-focus + Escape, not Tab cycling — adequate for these short popups, library can come later).
|
||||
- **Risk:** Adding `hidden` attribute to popups changes initial render — confirmed CSS does not depend on absence of `hidden` (CSS uses `.popup-show` to display). Need to check that `display: none` from `.popup` (default) and `hidden` don't conflict in unwanted ways; `hidden` is a stronger signal and should be fine.
|
||||
@ -49,7 +49,7 @@
|
||||
"url": "https://github.com/ether/etherpad.git"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.2",
|
||||
"license": "Apache-2.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
103
pnpm-lock.yaml
generated
103
pnpm-lock.yaml
generated
@ -80,14 +80,14 @@ importers:
|
||||
specifier: ^0.5.2
|
||||
version: 0.5.2(eslint@10.2.1)
|
||||
i18next:
|
||||
specifier: ^26.0.6
|
||||
version: 26.0.6(typescript@6.0.3)
|
||||
specifier: ^26.0.7
|
||||
version: 26.0.7(typescript@6.0.3)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.2.1
|
||||
version: 8.2.1
|
||||
lucide-react:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0(react@19.2.5)
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0(react@19.2.5)
|
||||
react:
|
||||
specifier: ^19.2.5
|
||||
version: 19.2.5
|
||||
@ -99,7 +99,7 @@ importers:
|
||||
version: 7.73.1(react@19.2.5)
|
||||
react-i18next:
|
||||
specifier: ^17.0.4
|
||||
version: 17.0.4(i18next@26.0.6(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
|
||||
version: 17.0.4(i18next@26.0.7(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
|
||||
react-router-dom:
|
||||
specifier: ^7.14.2
|
||||
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@ -115,9 +115,6 @@ importers:
|
||||
vite-plugin-babel:
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0(@babel/core@7.29.0)(rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0))
|
||||
vite-plugin-static-copy:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0))
|
||||
zustand:
|
||||
specifier: ^5.0.12
|
||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
|
||||
@ -196,8 +193,8 @@ importers:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
express-rate-limit:
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2(express@5.2.1)
|
||||
specifier: ^8.4.0
|
||||
version: 8.4.0(express@5.2.1)
|
||||
express-session:
|
||||
specifier: ^1.19.0
|
||||
version: 1.19.0
|
||||
@ -253,8 +250,8 @@ importers:
|
||||
specifier: ^7.1.1
|
||||
version: 7.2.0
|
||||
mssql:
|
||||
specifier: ^12.2.1
|
||||
version: 12.3.1(@azure/core-client@1.10.1)
|
||||
specifier: ^12.5.0
|
||||
version: 12.5.0(@azure/core-client@1.10.1)
|
||||
mysql2:
|
||||
specifier: ^3.22.2
|
||||
version: 3.22.2(@types/node@25.6.0)
|
||||
@ -548,16 +545,16 @@ packages:
|
||||
resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@azure/msal-browser@5.7.0':
|
||||
resolution: {integrity: sha512-uYbJ0YarxkVGWEq814BysJry/IPvpDNkVKmc2bMZp4G+igUQkJ5nlFirycwPGUeA9ICLQqCxqExCA1Z1E07bPA==}
|
||||
'@azure/msal-browser@5.8.0':
|
||||
resolution: {integrity: sha512-X7IZV77bN56l7sbLjkcbQJX1t3U4tgxqztDr/XFbUcUfKk+z2FavcLgKP+OYUNj0wl/pEEtV9lldW9siY8BuHQ==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
||||
'@azure/msal-common@16.5.0':
|
||||
resolution: {integrity: sha512-i3eS/5pmxDbIU/mLMENs88Qg3k6XxqJytJy6PpB7L1tCBjdXHJDadCD3Hu1TyTooe7iQo7CYqbocgL/l/8u90g==}
|
||||
'@azure/msal-common@16.5.1':
|
||||
resolution: {integrity: sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
||||
'@azure/msal-node@5.1.3':
|
||||
resolution: {integrity: sha512-LqT8mRZpEils9zGR9eW+Ljqifh2aMA99UF/X0jxIKDYZeHr6onlHwhVP4xHCeLhh55BI63JCbdf1iWJbMh1mPw==}
|
||||
'@azure/msal-node@5.1.4':
|
||||
resolution: {integrity: sha512-G4LXGGggok1QC48uKu64/SV2DPRDlddmV8EieK8pflsNYMj9/Zz+Y9OHoEBhT15h+zpdwXXLYA/7PJCR/yZ8aw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
@ -3181,8 +3178,8 @@ packages:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
express-rate-limit@8.3.2:
|
||||
resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==}
|
||||
express-rate-limit@8.4.0:
|
||||
resolution: {integrity: sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
@ -3537,8 +3534,8 @@ packages:
|
||||
i18next-browser-languagedetector@8.2.1:
|
||||
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
|
||||
|
||||
i18next@26.0.6:
|
||||
resolution: {integrity: sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==}
|
||||
i18next@26.0.7:
|
||||
resolution: {integrity: sha512-f7tL/iw0VQsx4nC5oNxBM2RjM8alNys5KzyiQTU6A9TI5TI89py4/Ez1cKFvHiLWsvzOXvuGUES+Kk/A2WiANQ==}
|
||||
peerDependencies:
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
@ -4067,8 +4064,8 @@ packages:
|
||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
|
||||
lucide-react@1.8.0:
|
||||
resolution: {integrity: sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==}
|
||||
lucide-react@1.11.0:
|
||||
resolution: {integrity: sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
@ -4233,6 +4230,11 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
mssql@12.5.0:
|
||||
resolution: {integrity: sha512-nTbhxS1qi5SPwuKygwfRzmp2p6e/2v37ZFzvwvMf27wRSI+09J7J2pP7zaAUzqT4znMyHYBrcUyxkjSeeNyDTg==}
|
||||
engines: {node: '>=18.19.0'}
|
||||
hasBin: true
|
||||
|
||||
mysql2@3.22.1:
|
||||
resolution: {integrity: sha512-48+9UXehKyxxiP2pqCxUq+MSFvX+v41jwsSpFDQO/jAoFuAELutBGJUhWJnDbe82/OBlIhSBMC82WeonmznT/Q==}
|
||||
engines: {node: '>= 8.0'}
|
||||
@ -5447,12 +5449,6 @@ packages:
|
||||
'@babel/core': ^7.0.0
|
||||
vite: '>=7.3.2'
|
||||
|
||||
vite-plugin-static-copy@4.1.0:
|
||||
resolution: {integrity: sha512-9XOarNV7LgP0KBB7AApxdgFikLXx3daZdqjC3AevYsL6MrUH62zphonLUs2a6LZc1HN1GY+vQdheZ8VVJb6dQQ==}
|
||||
engines: {node: ^22.0.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
vite: '>=7.3.2'
|
||||
|
||||
vite@8.0.8:
|
||||
resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@ -5851,8 +5847,8 @@ snapshots:
|
||||
'@azure/core-tracing': 1.3.1
|
||||
'@azure/core-util': 1.13.1
|
||||
'@azure/logger': 1.3.0
|
||||
'@azure/msal-browser': 5.7.0
|
||||
'@azure/msal-node': 5.1.3
|
||||
'@azure/msal-browser': 5.8.0
|
||||
'@azure/msal-node': 5.1.4
|
||||
open: 10.2.0
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
@ -5896,15 +5892,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@azure/msal-browser@5.7.0':
|
||||
'@azure/msal-browser@5.8.0':
|
||||
dependencies:
|
||||
'@azure/msal-common': 16.5.0
|
||||
'@azure/msal-common': 16.5.1
|
||||
|
||||
'@azure/msal-common@16.5.0': {}
|
||||
'@azure/msal-common@16.5.1': {}
|
||||
|
||||
'@azure/msal-node@5.1.3':
|
||||
'@azure/msal-node@5.1.4':
|
||||
dependencies:
|
||||
'@azure/msal-common': 16.5.0
|
||||
'@azure/msal-common': 16.5.1
|
||||
jsonwebtoken: 9.0.3
|
||||
uuid: 8.3.2
|
||||
|
||||
@ -8509,7 +8505,7 @@ snapshots:
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
express-rate-limit@8.3.2(express@5.2.1):
|
||||
express-rate-limit@8.4.0(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
ip-address: 10.1.0
|
||||
@ -8951,9 +8947,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
|
||||
i18next@26.0.6(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
i18next@26.0.7(typescript@6.0.3):
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
@ -9453,7 +9447,7 @@ snapshots:
|
||||
|
||||
lru.min@1.1.4: {}
|
||||
|
||||
lucide-react@1.8.0(react@19.2.5):
|
||||
lucide-react@1.11.0(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
@ -9608,6 +9602,17 @@ snapshots:
|
||||
- '@azure/core-client'
|
||||
- supports-color
|
||||
|
||||
mssql@12.5.0(@azure/core-client@1.10.1):
|
||||
dependencies:
|
||||
'@tediousjs/connection-string': 1.1.0
|
||||
commander: 11.1.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
tarn: 3.0.2
|
||||
tedious: 19.2.1(@azure/core-client@1.10.1)
|
||||
transitivePeerDependencies:
|
||||
- '@azure/core-client'
|
||||
- supports-color
|
||||
|
||||
mysql2@3.22.1(@types/node@25.6.0):
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
@ -9957,7 +9962,7 @@ snapshots:
|
||||
proxy-agent@6.5.0:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.1
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
lru-cache: 7.18.3
|
||||
@ -10005,11 +10010,11 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
react-i18next@17.0.4(i18next@26.0.6(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3):
|
||||
react-i18next@17.0.4(i18next@26.0.7(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 26.0.6(typescript@6.0.3)
|
||||
i18next: 26.0.7(typescript@6.0.3)
|
||||
react: 19.2.5
|
||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||
optionalDependencies:
|
||||
@ -10987,14 +10992,6 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
vite: rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0)
|
||||
|
||||
vite-plugin-static-copy@4.1.0(rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0)):
|
||||
dependencies:
|
||||
chokidar: 3.6.0
|
||||
p-map: 7.0.4
|
||||
picocolors: 1.1.1
|
||||
tinyglobby: 0.2.16
|
||||
vite: rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0)
|
||||
|
||||
vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(tsx@4.21.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
|
||||
@ -67,7 +67,10 @@ exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string,
|
||||
// if this is a plain text export, we can do this directly
|
||||
// We have to over engineer this because tabs are stored as attributes and not plain text
|
||||
if (type === 'etherpad') {
|
||||
const pad = await exportEtherpad.getPadRaw(padId, readOnlyId);
|
||||
// Honor the :rev URL segment on `.etherpad` exports the same way the
|
||||
// other formats already do — revNum limits the serialized pad to revs
|
||||
// 0..rev (issue #5071).
|
||||
const pad = await exportEtherpad.getPadRaw(padId, readOnlyId, req.params.rev);
|
||||
res.send(pad);
|
||||
} else if (type === 'txt') {
|
||||
const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev);
|
||||
|
||||
@ -21,12 +21,21 @@ const authorManager = require('../db/AuthorManager');
|
||||
const hooks = require('../../static/js/pluginfw/hooks');
|
||||
const padManager = require('../db/PadManager');
|
||||
|
||||
exports.getPadRaw = async (padId:string, readOnlyId:string) => {
|
||||
exports.getPadRaw = async (padId:string, readOnlyId:string, revNum?: number) => {
|
||||
const dstPfx = `pad:${readOnlyId || padId}`;
|
||||
const [pad, customPrefixes] = await Promise.all([
|
||||
padManager.getPad(padId),
|
||||
hooks.aCallAll('exportEtherpadAdditionalContent'),
|
||||
]);
|
||||
// If a rev limit was supplied, clamp to it and also clamp chat to the
|
||||
// timestamp-ordered window that ended at that rev. Without this, a rev=5
|
||||
// export on a pad with head=100 would still ship all 95 later revisions
|
||||
// (and leak their content via the exported .etherpad file) — which is
|
||||
// precisely what issue #5071 reported.
|
||||
const padHead: number = pad.head;
|
||||
const effectiveHead: number = (revNum == null || revNum > padHead) ? padHead : revNum;
|
||||
const isRevBound = revNum != null && revNum < padHead;
|
||||
const boundAtext = isRevBound ? await pad.getInternalRevisionAText(effectiveHead) : null;
|
||||
const pluginRecords = await Promise.all(customPrefixes.map(async (customPrefix:string) => {
|
||||
const srcPfx = `${customPrefix}:${padId}`;
|
||||
const dstPfx = `${customPrefix}:${readOnlyId || padId}`;
|
||||
@ -49,11 +58,18 @@ exports.getPadRaw = async (padId:string, readOnlyId:string) => {
|
||||
return authorEntry;
|
||||
})()];
|
||||
}
|
||||
for (let i = 0; i <= pad.head; ++i) yield [`${dstPfx}:revs:${i}`, pad.getRevision(i)];
|
||||
for (let i = 0; i <= effectiveHead; ++i) yield [`${dstPfx}:revs:${i}`, pad.getRevision(i)];
|
||||
for (let i = 0; i <= pad.chatHead; ++i) yield [`${dstPfx}:chat:${i}`, pad.getChatMessage(i)];
|
||||
for (const gen of pluginRecords) yield* gen;
|
||||
})();
|
||||
const data = {[dstPfx]: pad};
|
||||
// When rev-bound, serialize a shallow-cloned pad object with head/atext
|
||||
// rewritten so the import side reconstructs the pad at the requested rev.
|
||||
// toJSON() returns a plain object suitable for spreading; the live Pad
|
||||
// instance is kept for the exportEtherpad hook below.
|
||||
const serializedPad = isRevBound
|
||||
? {...(pad.toJSON()), head: effectiveHead, atext: boundAtext}
|
||||
: pad;
|
||||
const data = {[dstPfx]: serializedPad};
|
||||
for (const [dstKey, p] of new Stream(records).batch(100).buffer(99)) data[dstKey] = await p;
|
||||
await hooks.aCallAll('exportEtherpad', {
|
||||
pad,
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
"ejs": "^5.0.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"express-rate-limit": "^8.4.0",
|
||||
"express-session": "^1.19.0",
|
||||
"find-root": "1.1.0",
|
||||
"formidable": "^3.5.4",
|
||||
@ -59,7 +59,7 @@
|
||||
"measured-core": "^2.0.0",
|
||||
"mime-types": "^3.0.2",
|
||||
"mongodb": "^7.1.1",
|
||||
"mssql": "^12.2.1",
|
||||
"mssql": "^12.5.0",
|
||||
"mysql2": "^3.22.2",
|
||||
"nano": "^11.0.5",
|
||||
"oidc-provider": "9.8.2",
|
||||
@ -157,6 +157,6 @@
|
||||
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
|
||||
"test:vitest": "vitest"
|
||||
},
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.2",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
|
||||
@ -59,27 +59,51 @@
|
||||
}
|
||||
|
||||
/* -- TITLE BAR -- */
|
||||
/* Single flex row, vertically centred: [ CHAT _ [] ]
|
||||
- #titlelabel takes the remaining width so the controls sit at the
|
||||
right edge.
|
||||
- Source order is titlecross then titlesticky, which is also the
|
||||
desired visual order (minus on the left, sticky on the right). */
|
||||
#titlebar {
|
||||
font-weight: bold;
|
||||
padding: 5px;
|
||||
/* Equal horizontal padding so CHAT on the left and the sticky button on
|
||||
the right sit the same distance from the title-bar edges. */
|
||||
padding: 5px 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
#titlebar #titlelabel {
|
||||
margin: 4px 0 0 4px;
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
flex: 1;
|
||||
}
|
||||
#titlebar .stick-to-screen-btn,
|
||||
#titlebar .hide-reduce-btn {
|
||||
font-size: 25px;
|
||||
color: inherit;
|
||||
float: right;
|
||||
text-align: right;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
#titlebar .stick-to-screen-btn {
|
||||
font-size: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
/* The `_` glyph in #titlecross renders at the bottom of its em-box, which
|
||||
places it visibly far below the CHAT baseline. Lift it without changing
|
||||
the flex row layout. */
|
||||
#titlebar .hide-reduce-btn {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
#titlebar .stick-to-screen-btn:focus-visible,
|
||||
#titlebar .hide-reduce-btn:focus-visible {
|
||||
outline: 2px solid #0066cc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* -- MESSAGES -- */
|
||||
@ -121,10 +145,42 @@
|
||||
|
||||
/* -- CHAT ICON -- */
|
||||
#chaticon {
|
||||
/* #chaticon was converted from <span> to <button> for a11y; reset the
|
||||
UA-default button chrome so the corner icon keeps its pre-conversion
|
||||
shape. Deliberately do NOT reset `border` here — the 1px grey border
|
||||
is supplied earlier (#chaticon {border:1px solid #ccc; border-bottom:
|
||||
none}) and is part of the intended visual. Overriding with border:0
|
||||
visibly broke the icon. See PR #7584 review feedback. */
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
padding: 5px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
/* Scope: the inner .buttonicon span here is just a glyph holder. Its global
|
||||
rule in icons.css applies `display: flex; align-items: center;
|
||||
justify-content: center; position: relative;` which is fine for toolbar
|
||||
`<button class="buttonicon">` instances (where the class IS the button)
|
||||
but breaks the chat-icon corner widget — here `.buttonicon` is on a
|
||||
`<span>` *inside* the button, alongside two more inline `<span>`s for
|
||||
the label and unread counter. Turning the middle span into a flex
|
||||
container disrupts the inline row and, in some layouts, leaves it
|
||||
sitting on top of the button's clickable surface so clicks never reach
|
||||
the `<button>`'s own handler. Reset the flex behaviour and make the
|
||||
span pointer-transparent so clicks always fall through to #chaticon. */
|
||||
#chaticon .buttonicon {
|
||||
display: inline;
|
||||
align-items: initial;
|
||||
justify-content: initial;
|
||||
position: static;
|
||||
pointer-events: none;
|
||||
}
|
||||
#chaticon:focus-visible {
|
||||
outline: 2px solid #0066cc;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
#chaticon a {
|
||||
text-decoration: none
|
||||
|
||||
@ -53,6 +53,17 @@
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
/* When a settings checkbox is disabled (e.g. "Chat always on screen" while
|
||||
"Disable chat" is ticked), the browser greys the checkbox itself but the
|
||||
adjacent `<label>` still looks active, leaving the row visually clickable
|
||||
even though it isn't. Match the row to the checkbox's disabled state.
|
||||
Fixes #7592. */
|
||||
.popup input[type="checkbox"]:disabled + label,
|
||||
.popup input[type="radio"]:disabled + label {
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile devices */
|
||||
@media only screen and (max-width: 800px) {
|
||||
.popup {
|
||||
|
||||
@ -98,6 +98,15 @@
|
||||
}
|
||||
|
||||
.toolbar .show-more-icon-btn {
|
||||
/* Reset user-agent <button> styling introduced when this was converted
|
||||
from <span> for a11y. Without these the native button border/background
|
||||
leak through and shift the glyph off-centre. */
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
display:none;
|
||||
cursor: pointer;
|
||||
height: 39px;
|
||||
|
||||
@ -2879,22 +2879,36 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||
// This is required, browsers will try to do normal default behavior on
|
||||
// page up / down and the default behavior SUCKS
|
||||
evt.preventDefault();
|
||||
const oldVisibleLineRange = scroll.getVisibleLineRange(rep);
|
||||
let topOffset = rep.selStart[0] - oldVisibleLineRange[0];
|
||||
if (topOffset < 0) {
|
||||
topOffset = 0;
|
||||
}
|
||||
|
||||
const isPageDown = evt.which === 34;
|
||||
const isPageUp = evt.which === 33;
|
||||
|
||||
const oldVisibleLineRange = scroll.getVisibleLineRange(rep);
|
||||
let topOffset = rep.selStart[0] - oldVisibleLineRange[0];
|
||||
if (topOffset < 0) topOffset = 0;
|
||||
|
||||
scheduler.setTimeout(() => {
|
||||
// the visible lines IE 1,10
|
||||
const newVisibleLineRange = scroll.getVisibleLineRange(rep);
|
||||
// total count of lines in pad IE 10
|
||||
const linesCount = rep.lines.length();
|
||||
// How many lines are in the viewport right now?
|
||||
const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0];
|
||||
|
||||
// Calculate lines to skip based on viewport pixel height divided by
|
||||
// the average rendered line height. This correctly handles long wrapped
|
||||
// lines that consume multiple visual rows (fixes #4562).
|
||||
const viewportHeight = getInnerHeight();
|
||||
const visibleStart = newVisibleLineRange[0];
|
||||
const visibleEnd = newVisibleLineRange[1];
|
||||
let totalPixelHeight = 0;
|
||||
for (let i = visibleStart; i <= Math.min(visibleEnd, linesCount - 1); i++) {
|
||||
const entry = rep.lines.atIndex(i);
|
||||
if (entry && entry.lineNode) {
|
||||
totalPixelHeight += entry.lineNode.offsetHeight;
|
||||
}
|
||||
}
|
||||
const visibleLogicalLines = visibleEnd - visibleStart + 1;
|
||||
// Use pixel-based count: how many logical lines fit in one viewport
|
||||
const numberOfLinesInViewport = visibleLogicalLines > 0 && totalPixelHeight > 0
|
||||
? Math.max(1, Math.round(visibleLogicalLines * viewportHeight / totalPixelHeight))
|
||||
: Math.max(1, visibleLogicalLines);
|
||||
|
||||
if (isPageUp && padShortcutEnabled.pageUp) {
|
||||
rep.selStart[0] -= numberOfLinesInViewport;
|
||||
@ -2910,18 +2924,11 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||
rep.selStart[0] = Math.max(0, Math.min(rep.selStart[0], linesCount - 1));
|
||||
rep.selEnd[0] = Math.max(0, Math.min(rep.selEnd[0], linesCount - 1));
|
||||
updateBrowserSelectionFromRep();
|
||||
// get the current caret selection, can't use rep. here because that only gives
|
||||
// us the start position not the current
|
||||
// scroll to the caret position
|
||||
const myselection = targetDoc.getSelection();
|
||||
// get the carets selection offset in px IE 214
|
||||
let caretOffsetTop = myselection.focusNode.parentNode.offsetTop ||
|
||||
myselection.focusNode.offsetTop;
|
||||
|
||||
// sometimes the first selection is -1 which causes problems
|
||||
// (Especially with ep_page_view)
|
||||
// so use focusNode.offsetTop value.
|
||||
if (caretOffsetTop === -1) caretOffsetTop = myselection.focusNode.offsetTop;
|
||||
// set the scrollY offset of the viewport on the document
|
||||
scroll.setScrollY(caretOffsetTop);
|
||||
}, 200);
|
||||
}
|
||||
@ -2994,6 +3001,25 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||
lineAndColumnFromChar(selectionInfo.selStart),
|
||||
lineAndColumnFromChar(selectionInfo.selEnd),
|
||||
selectionInfo.selFocusAtStart);
|
||||
// Issue #7007: bring the caret's line into view after
|
||||
// undo/redo so the user can actually see the change that
|
||||
// just got reverted. The outer inCallStack's finally-block
|
||||
// scroll path is fragile on large pads — in particular
|
||||
// `scrollNodeVerticallyIntoView`'s caret-below-viewport
|
||||
// branch intentionally scrolls to a fixed offset to keep
|
||||
// the Enter-on-last-line experience smooth (see PR #4639),
|
||||
// which leaves undo/redo pointed at the wrong spot
|
||||
// whenever the caret jumps to a mid-document line. Using
|
||||
// Element.scrollIntoView with block:"center" is native,
|
||||
// framework-agnostic, and matches the behavior other
|
||||
// editors (gedit, libreoffice) use.
|
||||
const focusPoint = selectionInfo.selFocusAtStart
|
||||
? lineAndColumnFromChar(selectionInfo.selStart)
|
||||
: lineAndColumnFromChar(selectionInfo.selEnd);
|
||||
const caretLineNode = rep.lines.atIndex(focusPoint[0])?.lineNode;
|
||||
if (caretLineNode && typeof caretLineNode.scrollIntoView === 'function') {
|
||||
caretLineNode.scrollIntoView({block: 'center', behavior: 'auto'});
|
||||
}
|
||||
}
|
||||
const oldEvent = currentCallStack.startNewEvent(oldEventType, true);
|
||||
return oldEvent;
|
||||
|
||||
@ -36,7 +36,11 @@ exports.chat = (() => {
|
||||
show() {
|
||||
if (pad.settings.hideChat) return;
|
||||
$('#chaticon').removeClass('visible');
|
||||
$('#chatbox').addClass('visible');
|
||||
// Clear any inline `display: none` left by applyShowChat(false)'s
|
||||
// jQuery .hide() — without this, re-enabling chat then clicking the
|
||||
// icon would only add the .visible class while the box stayed hidden
|
||||
// by the inline style. The .visible class only flips visibility.
|
||||
$('#chatbox').css('display', '').addClass('visible');
|
||||
this.scrollDown(true);
|
||||
chatMentions = 0;
|
||||
Tinycon.setBubble(0);
|
||||
@ -263,6 +267,19 @@ exports.chat = (() => {
|
||||
|
||||
// initial messages are loaded in pad.js' _afterHandshake
|
||||
|
||||
$('#chaticon').on('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.show();
|
||||
});
|
||||
$('#titlecross').on('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.hide();
|
||||
});
|
||||
$('#titlesticky').on('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.stickToScreen(true);
|
||||
});
|
||||
|
||||
$('#chatcounter').text(0);
|
||||
$('#chatloadmessagesbutton').on('click', () => {
|
||||
const start = Math.max(this.historyPointer - 20, 0);
|
||||
|
||||
@ -66,8 +66,22 @@ class ToolbarItem {
|
||||
bind(callback) {
|
||||
if (this.isButton()) {
|
||||
this.$el.on('click', (event) => {
|
||||
// Stash the clicked button as the focus-restore target BEFORE we
|
||||
// blur :focus — but only for dropdown-opening buttons. Non-dropdown
|
||||
// commands (list toggles, bold, etc.) return focus to the ace editor
|
||||
// and should not touch _lastTrigger (it would retain a stale
|
||||
// reference and mess with later popup Esc-close focus handling).
|
||||
const cmd = this.getCommand();
|
||||
// @ts-ignore — padeditbar is the exported singleton defined below
|
||||
const isDropdownTrigger = exports.padeditbar.dropdowns.indexOf(cmd) !== -1;
|
||||
if (isDropdownTrigger) {
|
||||
const trigger = (this.$el.find('button')[0] as HTMLElement | undefined) ||
|
||||
(this.$el[0] as HTMLElement);
|
||||
// @ts-ignore
|
||||
if (trigger) exports.padeditbar._lastTrigger = trigger;
|
||||
}
|
||||
$(':focus').trigger('blur');
|
||||
callback(this.getCommand(), this);
|
||||
callback(cmd, this);
|
||||
event.preventDefault();
|
||||
});
|
||||
} else if (this.isSelect()) {
|
||||
@ -128,6 +142,7 @@ exports.padeditbar = new class {
|
||||
this._editbarPosition = 0;
|
||||
this.commands = {};
|
||||
this.dropdowns = [];
|
||||
this._lastTrigger = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
@ -144,8 +159,24 @@ exports.padeditbar = new class {
|
||||
this._bodyKeyEvent(evt);
|
||||
});
|
||||
|
||||
// After any toolbar-select change (e.g. ep_headings style picker,
|
||||
// ep_font_size), return keyboard focus to the pad editor so the caret
|
||||
// is back at its previous location. Plugin-provided <select> elements
|
||||
// aren't always wired through Button.bind (which requires data-key on
|
||||
// the wrapping <li>); covering them at the #editbar level means every
|
||||
// toolbar dropdown restores focus consistently. setTimeout(0) defers
|
||||
// the focus call until plugin change handlers (bound on the same
|
||||
// event) have finished, so their ace.callWithAce work is done before
|
||||
// we return focus. Fixes #7589.
|
||||
$('#editbar').on('change', 'select', () => {
|
||||
setTimeout(() => {
|
||||
if (padeditor.ace) padeditor.ace.focus();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
$('.show-more-icon-btn').on('click', () => {
|
||||
$('.toolbar').toggleClass('full-icons');
|
||||
const expanded = $('.toolbar').toggleClass('full-icons').hasClass('full-icons');
|
||||
$('.show-more-icon-btn').attr('aria-expanded', String(expanded));
|
||||
});
|
||||
this.checkAllIconsAreDisplayedInToolbar();
|
||||
$(window).on('resize', _.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
|
||||
@ -208,6 +239,19 @@ exports.padeditbar = new class {
|
||||
$('.nice-select').removeClass('open');
|
||||
$('.toolbar-popup').removeClass('popup-show');
|
||||
|
||||
// Remember the trigger so we can restore focus when the dialog closes.
|
||||
// The Button click handler pre-sets `_lastTrigger` before calling blur(),
|
||||
// because blur would make document.activeElement === <body>. For other
|
||||
// paths (keyboard shortcut, programmatic open) fall back to whatever has
|
||||
// focus right now.
|
||||
const wasAnyOpen = $('.popup.popup-show').length > 0;
|
||||
if (!wasAnyOpen && moduleName !== 'none' && !this._lastTrigger) {
|
||||
const active = document.activeElement;
|
||||
if (active && active !== document.body) this._lastTrigger = active;
|
||||
}
|
||||
|
||||
let openedModule = null;
|
||||
|
||||
// hide all modules and remove highlighting of all buttons
|
||||
if (moduleName === 'none') {
|
||||
for (const thisModuleName of this.dropdowns) {
|
||||
@ -236,9 +280,44 @@ exports.padeditbar = new class {
|
||||
} else if (thisModuleName === moduleName) {
|
||||
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
|
||||
module.addClass('popup-show');
|
||||
openedModule = module;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openedModule) {
|
||||
// Move focus into the now-visible popup so keyboard users land inside the dialog.
|
||||
// Skip if a command handler already placed focus inside this popup — the Embed
|
||||
// command focuses #linkinput deliberately, which is different from the first
|
||||
// tabbable element (a readonly checkbox) and should win.
|
||||
// Fallback: if no focusable descendant exists (e.g. #users where the only
|
||||
// input is disabled), focus the popup div itself so keydown events fire on
|
||||
// the outer document instead of being trapped in the ace editor iframe.
|
||||
const target = openedModule;
|
||||
requestAnimationFrame(() => {
|
||||
// If a command handler already placed focus inside the popup (e.g.
|
||||
// the Embed command focuses #linkinput, showusers focuses
|
||||
// #myusernameedit), honour that.
|
||||
if (target[0].contains(document.activeElement)) return;
|
||||
// Otherwise focus the popup container itself. This keeps keydown
|
||||
// events on the outer document (so Esc always dismisses the popup,
|
||||
// even when the popup has no directly-focusable descendants like
|
||||
// #users does), and it works uniformly across browsers without
|
||||
// getting tripped up by `visibility: hidden` nested popups.
|
||||
// Keyboard users can Tab from here into the popup's controls.
|
||||
if (!target.attr('tabindex')) target.attr('tabindex', '-1');
|
||||
target[0].focus();
|
||||
});
|
||||
} else if (wasAnyOpen && $('.popup.popup-show').length === 0 && this._lastTrigger) {
|
||||
// A popup was open at entry and is now closed — restore focus to the
|
||||
// trigger that opened it. Gated on `wasAnyOpen` so background callers
|
||||
// (e.g. connectivity-modal setup, periodic state handling) that
|
||||
// dispatch `toggleDropDown('none')` with no popup open don't yank
|
||||
// focus away from the editor to a stale toolbar button.
|
||||
const trigger = this._lastTrigger;
|
||||
this._lastTrigger = null;
|
||||
if (document.body.contains(trigger)) trigger.focus();
|
||||
}
|
||||
} catch (err) {
|
||||
cbErr = err || new Error(err);
|
||||
} finally {
|
||||
@ -289,6 +368,35 @@ exports.padeditbar = new class {
|
||||
}
|
||||
|
||||
_bodyKeyEvent(evt) {
|
||||
// Escape while any popup is open: close it. We don't restrict to
|
||||
// `:focus inside popup` because some popups (e.g. #users) have no
|
||||
// focusable content on open — focus stays in the ace editor iframe —
|
||||
// but Esc should still dismiss them for keyboard users.
|
||||
if (evt.keyCode === 27 && $('.popup.popup-show').length > 0) {
|
||||
// Manually close popups that toggleDropDown('none') can't close:
|
||||
// * #users — explicitly skipped by the 'none' branch of
|
||||
// toggleDropDown so switching between other popups doesn't
|
||||
// hide the user list. Close here unless pinned (stickyUsers).
|
||||
// * Popups opened outside the editbar framework that were never
|
||||
// registered as dropdowns (e.g. #mycolorpicker, toggled
|
||||
// directly by pad_userlist.ts). toggleDropDown iterates only
|
||||
// this.dropdowns so these are invisible to it.
|
||||
// Leave registered-dropdown popups (settings/embed/etc.) for
|
||||
// toggleDropDown('none') so its `wasAnyOpen` detection still sees
|
||||
// them as open and its focus-restore branch fires for the trigger.
|
||||
const registered = this.dropdowns;
|
||||
$('.popup.popup-show').each((_, el) => {
|
||||
const $p = $(el);
|
||||
const id = $p.attr('id') || '';
|
||||
if (id === 'users' && $p.hasClass('stickyUsers')) return;
|
||||
if (id !== 'users' && id !== '' && registered.indexOf(id) !== -1) return;
|
||||
$p.removeClass('popup-show');
|
||||
if (id) $(`li[data-key="${id}"] > a`).removeClass('selected');
|
||||
});
|
||||
this.toggleDropDown('none');
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
// If the event is Alt F9 or Escape & we're already in the editbar menu
|
||||
// Send the users focus back to the pad
|
||||
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
|
||||
|
||||
17
src/static/js/vendors/html10n.ts
vendored
17
src/static/js/vendors/html10n.ts
vendored
@ -662,9 +662,20 @@ export class Html10n {
|
||||
if (node.children.length === 0 || prop != 'textContent') {
|
||||
// @ts-ignore
|
||||
node[prop] = str.str!
|
||||
node.setAttribute("aria-label", str.str!); // Sets the aria-label
|
||||
// The idea of the above is that we always have an aria value
|
||||
// This might be a bit of an abrupt solution but let's see how it goes
|
||||
// Populate aria-label from the translation so screen readers get a
|
||||
// localized accessible name. Preserve an author-supplied aria-label
|
||||
// (one present in the template without a marker), but keep our own
|
||||
// html10n-generated values in sync across language changes by
|
||||
// overwriting them. The `data-l10n-aria-label` marker distinguishes
|
||||
// the two: set when we populate it, checked on subsequent passes so
|
||||
// `pad.applyLanguage()` refreshes the accessible name.
|
||||
// See PR #7584 review feedback.
|
||||
const generatedMarker = 'data-l10n-aria-label';
|
||||
if (!node.hasAttribute('aria-label') ||
|
||||
node.getAttribute(generatedMarker) === 'true') {
|
||||
node.setAttribute('aria-label', str.str!);
|
||||
node.setAttribute(generatedMarker, 'true');
|
||||
}
|
||||
} else {
|
||||
let children = node.childNodes,
|
||||
found = false
|
||||
|
||||
@ -109,9 +109,12 @@ select:hover, .nice-select:hover {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
[type="checkbox"]:checked:disabled + label,
|
||||
[type="checkbox"]:checked:disabled + label:before,
|
||||
[type="checkbox"]:checked:disabled + label:after {
|
||||
/* Apply to any disabled checkbox (checked or unchecked), not just checked
|
||||
ones, so dependent toggles like "Chat always on screen" visibly grey out
|
||||
when "Disable chat" is ticked. Fixes #7592. */
|
||||
[type="checkbox"]:disabled + label,
|
||||
[type="checkbox"]:disabled + label:before,
|
||||
[type="checkbox"]:disabled + label:after {
|
||||
cursor: not-allowed;
|
||||
opacity: .4;
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ table#otheruserstable {
|
||||
}
|
||||
|
||||
#myusernameform {
|
||||
margin-left: 35px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
input#myusernameedit {
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
<%
|
||||
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs;
|
||||
var renderLang = (req && typeof req.acceptsLanguages === 'function'
|
||||
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
|
||||
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
|
||||
%>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="<%=renderLang%>" dir="<%=renderDir%>">
|
||||
|
||||
<title><%=settings.title%></title>
|
||||
<meta charset="utf-8">
|
||||
|
||||
@ -2,9 +2,12 @@
|
||||
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs
|
||||
, pluginUtils = require('ep_etherpad-lite/static/js/pluginfw/shared')
|
||||
;
|
||||
var renderLang = (req && typeof req.acceptsLanguages === 'function'
|
||||
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
|
||||
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
|
||||
%>
|
||||
<!doctype html>
|
||||
<html translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
|
||||
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
|
||||
<head>
|
||||
<% e.begin_block("htmlHead"); %>
|
||||
<% e.end_block(); %>
|
||||
@ -71,7 +74,7 @@
|
||||
<%- toolbar.menu(settings.toolbar.right, isReadOnly, 'right', 'pad') %>
|
||||
<% e.end_block(); %>
|
||||
</ul>
|
||||
<span class="show-more-icon-btn"></span> <!-- use on small screen to display hidden toolbar buttons -->
|
||||
<button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
|
||||
</div>
|
||||
|
||||
<% e.begin_block("afterEditbar"); %><% e.end_block(); %>
|
||||
@ -114,11 +117,11 @@
|
||||
<!-- SETTINGS POPUP (change font, language, chat parameters) -->
|
||||
<!------------------------------------------------------------->
|
||||
|
||||
<div id="settings" class="popup"><div class="popup-content">
|
||||
<div id="settings" class="popup" role="dialog" aria-modal="true" aria-labelledby="settings-title"><div class="popup-content">
|
||||
<% if (settings.enablePadWideSettings) { %>
|
||||
<h1 data-l10n-id="pad.settings.title">Settings</h1>
|
||||
<h1 id="settings-title" data-l10n-id="pad.settings.title">Settings</h1>
|
||||
<% } else { %>
|
||||
<h1 data-l10n-id="pad.settings.padSettings">Pad Settings</h1>
|
||||
<h1 id="settings-title" data-l10n-id="pad.settings.padSettings">Pad Settings</h1>
|
||||
<% } %>
|
||||
<div class="settings-sections">
|
||||
<div id="user-settings-section" class="settings-section">
|
||||
@ -252,8 +255,8 @@
|
||||
<!-- IMPORT EXPORT POPUP -->
|
||||
<!------------------------->
|
||||
|
||||
<div id="import_export" class="popup"><div class="popup-content">
|
||||
<h1 data-l10n-id="pad.importExport.import_export"></h1>
|
||||
<div id="import_export" class="popup" role="dialog" aria-modal="true" aria-labelledby="importexport-title"><div class="popup-content">
|
||||
<h1 id="importexport-title" data-l10n-id="pad.importExport.import_export"></h1>
|
||||
<div class="acl-write">
|
||||
<% e.begin_block("importColumn"); %>
|
||||
<h2 data-l10n-id="pad.importExport.import"></h2>
|
||||
@ -304,7 +307,7 @@
|
||||
<!-- CONNECTIVITY POPUP (when you get disconnected) -->
|
||||
<!---------------------------------------------------->
|
||||
|
||||
<div id="connectivity" class="popup"><div class="popup-content">
|
||||
<div id="connectivity" class="popup" role="dialog" aria-modal="true" aria-label="Connection status"><div class="popup-content">
|
||||
<% e.begin_block("modals"); %>
|
||||
<div class="connected visible">
|
||||
<h2 data-l10n-id="pad.modals.connected"></h2>
|
||||
@ -387,9 +390,9 @@
|
||||
<!-- EMBED POPUP (Share, embed) -->
|
||||
<!-------------------------------->
|
||||
|
||||
<div id="embed" class="popup"><div class="popup-content">
|
||||
<div id="embed" class="popup" role="dialog" aria-modal="true" aria-labelledby="embed-title"><div class="popup-content">
|
||||
<% e.begin_block("embedPopup"); %>
|
||||
<h1 data-l10n-id="pad.share"></h1>
|
||||
<h1 id="embed-title" data-l10n-id="pad.share"></h1>
|
||||
<div id="embedreadonly" class="acl-write">
|
||||
<input type="checkbox" id="readonlyinput">
|
||||
<label for="readonlyinput" data-l10n-id="pad.share.readonly"></label>
|
||||
@ -411,11 +414,11 @@
|
||||
<!-- USERS POPUP (set username, color, see other users names & color) -->
|
||||
<!---------------------------------------------------------------------->
|
||||
|
||||
<div id="users" class="popup"><div class="popup-content">
|
||||
<div id="users" class="popup" role="dialog" aria-modal="true" aria-label="Users on this pad"><div class="popup-content">
|
||||
<% e.begin_block("userlist"); %>
|
||||
<div id="connectionstatus"></div>
|
||||
<div id="myuser">
|
||||
<div id="mycolorpicker" class="popup"><div class="popup-content">
|
||||
<div id="mycolorpicker" class="popup" role="dialog" aria-modal="true" aria-label="Choose your author color"><div class="popup-content">
|
||||
<div id="colorpicker"></div>
|
||||
<div class="btn-container">
|
||||
<button id="mycolorpickersave" data-l10n-id="pad.colorpicker.save" class="btn btn-primary"></button>
|
||||
@ -428,7 +431,7 @@
|
||||
<input type="text" id="myusernameedit" disabled="disabled" data-l10n-id="pad.userlist.entername">
|
||||
</div>
|
||||
</div>
|
||||
<div id="otherusers" role="document">
|
||||
<div id="otherusers" role="region" aria-live="polite" aria-label="Active users on this pad">
|
||||
<table id="otheruserstable" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr><td></td></tr>
|
||||
</table>
|
||||
@ -442,18 +445,18 @@
|
||||
<!----------- CHAT ------------>
|
||||
<!----------------------------->
|
||||
|
||||
<div id="chaticon" class="visible" onclick="chat.show();return false;" title="Chat (Alt C)">
|
||||
<button type="button" id="chaticon" class="visible" title="Chat (Alt C)" data-l10n-id="pad.chat.title">
|
||||
<span id="chatlabel" data-l10n-id="pad.chat"></span>
|
||||
<span class="buttonicon buttonicon-chat"></span>
|
||||
<span id="chatcounter">0</span>
|
||||
</div>
|
||||
<span class="buttonicon buttonicon-chat" aria-hidden="true"></span>
|
||||
<span id="chatcounter" aria-label="Unread messages">0</span>
|
||||
</button>
|
||||
|
||||
<div id="chatbox">
|
||||
<div class="chat-content">
|
||||
<div id="titlebar">
|
||||
<h1 id ="titlelabel" data-l10n-id="pad.chat"></h1>
|
||||
<a id="titlecross" class="hide-reduce-btn" onClick="chat.hide();return false;">- </a>
|
||||
<a id="titlesticky" class="stick-to-screen-btn" onClick="chat.stickToScreen(true);return false;" data-l10n-id="pad.chat.stick.title">█ </a>
|
||||
<button type="button" id="titlecross" class="hide-reduce-btn" aria-label="Close chat">_</button>
|
||||
<button type="button" id="titlesticky" class="stick-to-screen-btn" data-l10n-id="pad.chat.stick.title">█</button>
|
||||
</div>
|
||||
<div id="chattext" class="thin-scrollbar" aria-live="polite" aria-relevant="additions removals text" role="log" aria-atomic="false">
|
||||
<div alt="loading.." id="chatloadmessagesball" class="chatloadmessages loadingAnimation" align="top"></div>
|
||||
@ -472,8 +475,8 @@
|
||||
<!-- SKIN VARIANTS BUILDER (Customize rendering, only for admins) -->
|
||||
<!------------------------------------------------------------------>
|
||||
<% if (settings.skinName == 'colibris') { %>
|
||||
<div id="skin-variants" class="popup"><div class="popup-content">
|
||||
<h1>Skin Builder</h1>
|
||||
<div id="skin-variants" class="popup" role="dialog" aria-modal="true" aria-labelledby="skin-variants-title"><div class="popup-content">
|
||||
<h1 id="skin-variants-title">Skin Builder</h1>
|
||||
|
||||
<div class="dropdowns-container">
|
||||
<% containers = [ "toolbar", "background", "editor" ]; %>
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<%
|
||||
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs
|
||||
var renderLang = (req && typeof req.acceptsLanguages === 'function'
|
||||
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
|
||||
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
|
||||
%>
|
||||
<!doctype html>
|
||||
<html translate="no" class="pad <%=settings.skinVariants%>">
|
||||
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=settings.skinVariants%>">
|
||||
<head>
|
||||
<title data-l10n-id="timeslider.pageTitle" data-l10n-args='{ "appTitle": "<%=settings.title%>" }'><%=settings.title%> Timeslider</title>
|
||||
<script>
|
||||
|
||||
@ -64,4 +64,52 @@ describe(__filename, function () {
|
||||
assert(!(`custom:${padId}x:foo` in data));
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/ether/etherpad/issues/5071.
|
||||
// `/p/:pad/:rev/export/etherpad` and getPadRaw() historically ignored the
|
||||
// rev parameter and always exported the full history, surprising users
|
||||
// who wanted to back up or inspect an earlier snapshot.
|
||||
describe('revNum bounding (issue #5071)', function () {
|
||||
const addRevs = async (pad: any, n: number) => {
|
||||
// Each call to .appendRevision bumps head by one, producing a
|
||||
// distinct revision we can count in the exported payload.
|
||||
for (let i = 0; i < n; i++) {
|
||||
await pad.appendText(`line ${i}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
it('defaults to full history when revNum is omitted', async function () {
|
||||
const pad = await padManager.getPad(padId);
|
||||
await addRevs(pad, 3);
|
||||
const data = await exportEtherpad.getPadRaw(padId, null);
|
||||
// revs 0 (pad-create) through pad.head inclusive.
|
||||
const revKeys =
|
||||
Object.keys(data).filter((k) => k.startsWith(`pad:${padId}:revs:`));
|
||||
assert.equal(revKeys.length, pad.head + 1);
|
||||
assert.equal(data[`pad:${padId}`].head, pad.head);
|
||||
});
|
||||
|
||||
it('limits exported revisions to 0..revNum when supplied', async function () {
|
||||
const pad = await padManager.getPad(padId);
|
||||
await addRevs(pad, 5);
|
||||
const bound = 2;
|
||||
const data = await exportEtherpad.getPadRaw(padId, null, bound);
|
||||
const revKeys =
|
||||
Object.keys(data).filter((k) => k.startsWith(`pad:${padId}:revs:`));
|
||||
assert.equal(revKeys.length, bound + 1,
|
||||
`expected ${bound + 1} revisions, got ${revKeys.length}`);
|
||||
assert(!(`pad:${padId}:revs:${bound + 1}` in data),
|
||||
'rev after bound must not be exported');
|
||||
// The serialized pad must also reflect the bounded head so that
|
||||
// re-importing reconstructs the pad at the requested rev.
|
||||
assert.equal(data[`pad:${padId}`].head, bound);
|
||||
});
|
||||
|
||||
it('treats a revNum above head as equivalent to full history', async function () {
|
||||
const pad = await padManager.getPad(padId);
|
||||
await addRevs(pad, 3);
|
||||
const data = await exportEtherpad.getPadRaw(padId, null, pad.head + 100);
|
||||
assert.equal(data[`pad:${padId}`].head, pad.head);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
34
src/tests/frontend-new/admin-spec/admini18n.spec.ts
Normal file
34
src/tests/frontend-new/admin-spec/admini18n.spec.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {loginToAdmin} from "../helper/adminhelper";
|
||||
|
||||
// Regression coverage for https://github.com/ether/etherpad/issues/7586
|
||||
//
|
||||
// 2.7.0 shipped with the admin SPA's locale files copied to a wrong
|
||||
// build path; fetches for them silently fell back to the SPA's
|
||||
// index.html, JSON.parse failed, and every <Trans> rendered as its
|
||||
// raw key. None of the existing admin specs asserted on translated
|
||||
// strings, so the regression slipped through. We now bundle the
|
||||
// translations through Vite (import.meta.glob) — these tests pin the
|
||||
// rendered behaviour rather than the file path so any future
|
||||
// loading-mechanism change is covered too.
|
||||
test.beforeEach(async ({ page })=>{
|
||||
await loginToAdmin(page, 'admin', 'changeme1');
|
||||
});
|
||||
|
||||
test.describe('admin i18n', () => {
|
||||
test('renders translated text on /admin (default English)', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/');
|
||||
// HomePage renders <h1><Trans i18nKey="admin_plugins"/></h1>. If
|
||||
// translations fail to load, the visible text becomes the raw key
|
||||
// "admin_plugins". Asserting on the translated form catches that.
|
||||
await expect(page.locator('h1', { hasText: /^Plugin manager$/ }))
|
||||
.toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByText('admin_plugins', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('switches language to German via ?lng=de', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/?lng=de');
|
||||
await expect(page.locator('h1', { hasText: /^Pluginverwaltung$/ }))
|
||||
.toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
});
|
||||
135
src/tests/frontend-new/specs/a11y_dialogs.spec.ts
Normal file
135
src/tests/frontend-new/specs/a11y_dialogs.spec.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {goToNewPad} from '../helper/padHelper';
|
||||
|
||||
test.beforeEach(async ({page}) => {
|
||||
await goToNewPad(page);
|
||||
});
|
||||
|
||||
test('html element has a non-empty lang attribute', async ({page}) => {
|
||||
const lang = await page.locator('html').getAttribute('lang');
|
||||
expect(lang).toBeTruthy();
|
||||
expect(lang!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('settings popup has dialog semantics, Escape closes it, focus returns to trigger', async ({page}) => {
|
||||
const settingsButton = page.locator('button[data-l10n-id="pad.toolbar.settings.title"]');
|
||||
await settingsButton.click();
|
||||
|
||||
const dialog = page.locator('#settings');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toHaveAttribute('aria-labelledby', 'settings-title');
|
||||
await expect(dialog).toHaveClass(/popup-show/);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toHaveClass(/popup-show/);
|
||||
|
||||
// Focus should return to the trigger button (the cog icon).
|
||||
const focusedL10nId =
|
||||
await page.evaluate(() => document.activeElement?.getAttribute('data-l10n-id') || '');
|
||||
expect(focusedL10nId).toBe('pad.toolbar.settings.title');
|
||||
});
|
||||
|
||||
test('import_export popup has dialog semantics', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.import_export.title"]').click();
|
||||
const dialog = page.locator('#import_export');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toHaveAttribute('aria-labelledby', 'importexport-title');
|
||||
});
|
||||
|
||||
test('embed popup has dialog semantics', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.embed.title"]').click();
|
||||
const dialog = page.locator('#embed');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toHaveAttribute('aria-labelledby', 'embed-title');
|
||||
});
|
||||
|
||||
test('users popup has dialog semantics with aria-label', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.showusers.title"]').click();
|
||||
const dialog = page.locator('#users');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toHaveAttribute('aria-label', 'Users on this pad');
|
||||
});
|
||||
|
||||
test('users popup closes on Escape even when focus is outside the popup', async ({page}) => {
|
||||
// Opening #users leaves focus in the ace editor iframe because its only
|
||||
// would-be-focusable element (#myusernameedit) is disabled. Esc must still
|
||||
// dismiss the dialog. Regression for PR #7584 review feedback.
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.showusers.title"]').click();
|
||||
const dialog = page.locator('#users');
|
||||
await expect(dialog).toHaveClass(/popup-show/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toHaveClass(/popup-show/);
|
||||
});
|
||||
|
||||
test('export links have an accessible name from their localized content', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.import_export.title"]').click();
|
||||
// The Word/PDF/ODF export links are removed client-side by pad_impexp.ts
|
||||
// when soffice is not configured, so only assert on links that the
|
||||
// environment actually renders. For the ones that are present, their
|
||||
// accessible name comes from the localized child span (data-l10n-id
|
||||
// pad.importExport.exportetherpad etc.), not a hard-coded English
|
||||
// aria-label. Assert the visible text is non-empty, which is what a
|
||||
// screen reader will announce.
|
||||
const ids = [
|
||||
'#exportetherpada',
|
||||
'#exporthtmla',
|
||||
'#exportplaina',
|
||||
'#exportworda',
|
||||
'#exportpdfa',
|
||||
'#exportopena',
|
||||
];
|
||||
for (const id of ids) {
|
||||
const locator = page.locator(id);
|
||||
if ((await locator.count()) === 0) continue;
|
||||
const text = (await locator.innerText()).trim();
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('chaticon is a button with an accessible name', async ({page}) => {
|
||||
const chatIcon = page.locator('#chaticon');
|
||||
const tagName = await chatIcon.evaluate((el) => el.tagName.toLowerCase());
|
||||
expect(tagName).toBe('button');
|
||||
// aria-label is populated by html10n from the pad.chat.title translation,
|
||||
// so we assert it is non-empty rather than a specific English string.
|
||||
const label = await chatIcon.getAttribute('aria-label');
|
||||
expect(label && label.length > 0).toBe(true);
|
||||
});
|
||||
|
||||
test('chat header close/pin controls are buttons with accessible names', async ({page}) => {
|
||||
await page.locator('#chaticon').click();
|
||||
// #titlecross has no data-l10n-id so its aria-label stays static English.
|
||||
// #titlesticky has data-l10n-id, so html10n fills aria-label from the
|
||||
// translation; assert non-empty rather than a specific value.
|
||||
const close = page.locator('#titlecross');
|
||||
expect(await close.evaluate((n) => n.tagName.toLowerCase())).toBe('button');
|
||||
await expect(close).toHaveAttribute('aria-label', 'Close chat');
|
||||
|
||||
const sticky = page.locator('#titlesticky');
|
||||
expect(await sticky.evaluate((n) => n.tagName.toLowerCase())).toBe('button');
|
||||
const stickyLabel = await sticky.getAttribute('aria-label');
|
||||
expect(stickyLabel && stickyLabel.length > 0).toBe(true);
|
||||
});
|
||||
|
||||
test('otherusers region has aria-live and aria-label (no aria-role typo)', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.showusers.title"]').click();
|
||||
const region = page.locator('#otherusers');
|
||||
await expect(region).toHaveAttribute('role', 'region');
|
||||
await expect(region).toHaveAttribute('aria-live', 'polite');
|
||||
await expect(region).toHaveAttribute('aria-label', 'Active users on this pad');
|
||||
// The deprecated aria-role attribute should not appear.
|
||||
const ariaRole = await region.getAttribute('aria-role');
|
||||
expect(ariaRole).toBeNull();
|
||||
});
|
||||
|
||||
test('show-more toolbar button has aria-label and aria-expanded', async ({page}) => {
|
||||
const btn = page.locator('.show-more-icon-btn');
|
||||
const tag = await btn.evaluate((el) => el.tagName.toLowerCase());
|
||||
expect(tag).toBe('button');
|
||||
await expect(btn).toHaveAttribute('aria-label', 'Show more toolbar buttons');
|
||||
await expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
@ -33,3 +33,28 @@ test('Own user name is shown when you enter a chat', async ({page})=> {
|
||||
expect(chatText).toContain('😃')
|
||||
expect(chatText).toContain(chatMessage)
|
||||
});
|
||||
|
||||
// #7593 review: the previous fix capped #myusernameform at 75px so a plugin-
|
||||
// supplied "Log out" button wouldn't overflow, but vanilla etherpad-lite has
|
||||
// no such button and the cap just made the username field too small. The
|
||||
// colibris skin also pre-existing override of margin-left:35px (chosen for
|
||||
// the chatAndUsers sticky layout) has been aligned with the base 10px.
|
||||
test('#myusernameform has 10px left margin and is not width-capped', async ({page}) => {
|
||||
await toggleUserList(page);
|
||||
|
||||
const styles = await page.evaluate(() => {
|
||||
const form = document.querySelector('#myusernameform') as HTMLElement;
|
||||
const input = document.querySelector('#myusernameedit') as HTMLElement;
|
||||
return {
|
||||
formMarginLeft: getComputedStyle(form).marginLeft,
|
||||
formWidth: getComputedStyle(form).width,
|
||||
inputWidth: getComputedStyle(input).width,
|
||||
};
|
||||
});
|
||||
|
||||
expect(styles.formMarginLeft).toBe('10px');
|
||||
// The form should size to its content / parent flex behaviour, NOT be capped
|
||||
// at 75px — width should comfortably exceed that.
|
||||
expect(parseFloat(styles.formWidth)).toBeGreaterThan(80);
|
||||
expect(parseFloat(styles.inputWidth)).toBeGreaterThan(80);
|
||||
});
|
||||
|
||||
@ -115,3 +115,79 @@ test('Checks showChat=false URL Parameter hides chat then' +
|
||||
// chat should be visible.
|
||||
expect(await secondChatIcon.isVisible()).toBe(true)
|
||||
});
|
||||
|
||||
// Regression: applyShowChat(false) sets inline `display: none` on #chatbox via
|
||||
// jQuery .hide(); re-enabling chat doesn't undo it, and chat.show() only flips
|
||||
// visibility via the .visible class — so without an explicit display reset the
|
||||
// box stays hidden by the lingering inline style. (PR #7597)
|
||||
test('chat icon click reveals chatbox after a disable → enable cycle', async ({page}) => {
|
||||
await showSettings(page);
|
||||
await page.locator('label[for="options-disablechat"]').click();
|
||||
await expect(page.locator('#options-disablechat')).toBeChecked();
|
||||
await expect(page.locator('#chaticon')).toBeHidden();
|
||||
|
||||
await page.locator('label[for="options-disablechat"]').click();
|
||||
await expect(page.locator('#options-disablechat')).not.toBeChecked();
|
||||
await expect(page.locator('#chaticon')).toBeVisible();
|
||||
await hideSettings(page);
|
||||
|
||||
await showChat(page);
|
||||
await expect(page.locator('#chatbox')).toBeVisible();
|
||||
await expect(page.locator('#chatbox')).toHaveClass(/visible/);
|
||||
});
|
||||
|
||||
// Title-bar layout / glyph regressions from #7590 review.
|
||||
test('chat title bar lays out as a centred flex row with underscore minimize', async ({page}) => {
|
||||
await showChat(page);
|
||||
|
||||
// Minimize button uses an underscore (sits at the bottom of its em-box and
|
||||
// reads as a proper minimize indicator); it must not silently revert to
|
||||
// − or a hyphen.
|
||||
await expect(page.locator('#titlecross')).toHaveText('_');
|
||||
|
||||
const styles = await page.evaluate(() => {
|
||||
const cs = (sel: string) => getComputedStyle(document.querySelector(sel)!);
|
||||
const rect = (sel: string) => document.querySelector(sel)!.getBoundingClientRect();
|
||||
const tb = rect('#titlebar');
|
||||
const lab = rect('#titlelabel');
|
||||
const sticky = rect('#titlesticky');
|
||||
return {
|
||||
titlebarDisplay: cs('#titlebar').display,
|
||||
titlebarAlignItems: cs('#titlebar').alignItems,
|
||||
labelFlex: cs('#titlelabel').flexGrow,
|
||||
crossFloat: cs('#titlecross').float,
|
||||
crossTransform: cs('#titlecross').transform,
|
||||
stickyFloat: cs('#titlesticky').float,
|
||||
// Visual symmetry — CHAT's left edge sits roughly the same distance
|
||||
// from the title-bar left edge as the rightmost button sits from the
|
||||
// right edge. Tested via rendered geometry rather than CSS literal so
|
||||
// we don't get tripped up by skin overrides (colibris ships its own
|
||||
// #titlebar padding rule).
|
||||
leftGap: lab.left - tb.left,
|
||||
rightGap: tb.right - sticky.right,
|
||||
};
|
||||
});
|
||||
expect(styles.titlebarDisplay).toBe('flex');
|
||||
expect(styles.titlebarAlignItems).toBe('center');
|
||||
// Title takes the remaining width so corner buttons sit at the right edge.
|
||||
expect(styles.labelFlex).toBe('1');
|
||||
// Buttons are flex items, not floats — old `float: right` layout must stay gone.
|
||||
expect(styles.crossFloat).toBe('none');
|
||||
expect(styles.stickyFloat).toBe('none');
|
||||
// 5px lift on #titlecross so the `_` glyph reads near the title's baseline
|
||||
// rather than at the very bottom of the row.
|
||||
expect(styles.crossTransform).not.toBe('none');
|
||||
// Padding looks symmetric (within 2px to allow for sub-pixel rounding).
|
||||
expect(Math.abs(styles.leftGap - styles.rightGap)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Regression: #chaticon was a <div> before the #7584 a11y refactor; once it
|
||||
// became a <button>, the inner <span class="buttonicon"> being `display: flex`
|
||||
// (from the global icons.css rule) could intercept clicks and the chat icon
|
||||
// stopped opening the panel. The fix scopes a reset on `#chaticon .buttonicon`.
|
||||
test('chat icon click reliably opens the chat box', async ({page}) => {
|
||||
await expect(page.locator('#chaticon')).toBeVisible();
|
||||
await page.locator('#chaticon').click();
|
||||
await expect(page.locator('#chatbox')).toHaveClass(/visible/);
|
||||
await expect(page.locator('#chatbox')).toBeVisible();
|
||||
});
|
||||
|
||||
@ -160,4 +160,38 @@ test.describe('creator-owned pad settings', () => {
|
||||
|
||||
await context2.close();
|
||||
});
|
||||
|
||||
// #7592: ticking "Disable chat" must visibly disable the dependent
|
||||
// "Chat always on screen" / "Show Chat and Users" toggles, not just
|
||||
// make the underlying inputs non-interactive.
|
||||
test('disabling chat disables and visually greys the dependent chat toggles', async ({page}) => {
|
||||
await goToNewPad(page);
|
||||
await showSettings(page);
|
||||
|
||||
// Initial state: dependent toggles are interactive.
|
||||
await expect(page.locator('#options-stickychat')).toBeEnabled();
|
||||
await expect(page.locator('#options-chatandusers')).toBeEnabled();
|
||||
|
||||
await page.locator('label[for="options-disablechat"]').click();
|
||||
await expect(page.locator('#options-disablechat')).toBeChecked();
|
||||
|
||||
// Inputs become disabled (refreshMyViewControls in pad.ts).
|
||||
await expect(page.locator('#options-stickychat')).toBeDisabled();
|
||||
await expect(page.locator('#options-chatandusers')).toBeDisabled();
|
||||
|
||||
// Colibris toggle visualisation dims via opacity:.4 on the label
|
||||
// (covers the hidden checkbox + before/after pseudo-elements).
|
||||
const stickyLabelOpacity = await page.evaluate(
|
||||
() => getComputedStyle(document.querySelector('label[for="options-stickychat"]')!).opacity);
|
||||
const chatAndUsersLabelOpacity = await page.evaluate(
|
||||
() => getComputedStyle(document.querySelector('label[for="options-chatandusers"]')!).opacity);
|
||||
expect(parseFloat(stickyLabelOpacity)).toBeLessThan(1);
|
||||
expect(parseFloat(chatAndUsersLabelOpacity)).toBeLessThan(1);
|
||||
|
||||
// Untick "Disable chat" → dependent toggles are interactive again.
|
||||
await page.locator('label[for="options-disablechat"]').click();
|
||||
await expect(page.locator('#options-disablechat')).not.toBeChecked();
|
||||
await expect(page.locator('#options-stickychat')).toBeEnabled();
|
||||
await expect(page.locator('#options-chatandusers')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -84,6 +84,65 @@ test.describe('Page Up / Page Down', function () {
|
||||
expect(selection).toBeLessThan(50);
|
||||
});
|
||||
|
||||
// Regression test for #4562: consecutive very long wrapped lines should not
|
||||
// cause PageDown/PageUp to skip too many or too few logical lines. The
|
||||
// pixel-based calculation must account for lines that occupy far more visual
|
||||
// rows than the viewport height.
|
||||
test('PageDown with consecutive long wrapped lines moves by correct amount (#4562)', async function ({page}) {
|
||||
const padBody = await getPadBody(page);
|
||||
await clearPadContent(page);
|
||||
|
||||
// Build a pad with long lines interspersed with short ones via the inner
|
||||
// document directly to avoid slow keyboard.type on Firefox.
|
||||
const longLine = 'word '.repeat(300);
|
||||
const innerFrame = page.frame('ace_inner')!;
|
||||
await innerFrame.evaluate((text: string) => {
|
||||
const body = document.getElementById('innerdocbody')!;
|
||||
body.innerHTML = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const longDiv = document.createElement('div');
|
||||
longDiv.textContent = text;
|
||||
body.appendChild(longDiv);
|
||||
const shortDiv = document.createElement('div');
|
||||
shortDiv.textContent = `short ${i}`;
|
||||
body.appendChild(shortDiv);
|
||||
}
|
||||
}, longLine);
|
||||
// Wait for Etherpad to process the DOM changes
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Move caret to the very top
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.up('Control');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Press PageDown twice and verify caret advances each time
|
||||
const getCaretLine = async () => {
|
||||
return innerFrame.evaluate(() => {
|
||||
const sel = document.getSelection();
|
||||
if (!sel || !sel.focusNode) return -1;
|
||||
let node = sel.focusNode as HTMLElement;
|
||||
while (node && node.tagName !== 'DIV') node = node.parentElement!;
|
||||
if (!node) return -1;
|
||||
const divs = Array.from(document.getElementById('innerdocbody')!.children);
|
||||
return divs.indexOf(node);
|
||||
});
|
||||
};
|
||||
|
||||
const lineBefore = await getCaretLine();
|
||||
|
||||
await page.keyboard.press('PageDown');
|
||||
await page.waitForTimeout(1000);
|
||||
const lineAfterFirst = await getCaretLine();
|
||||
expect(lineAfterFirst).toBeGreaterThan(lineBefore);
|
||||
|
||||
await page.keyboard.press('PageDown');
|
||||
await page.waitForTimeout(1000);
|
||||
const lineAfterSecond = await getCaretLine();
|
||||
expect(lineAfterSecond).toBeGreaterThan(lineAfterFirst);
|
||||
});
|
||||
|
||||
test('PageDown then PageUp returns to approximately same position', async function ({page}) {
|
||||
const padBody = await getPadBody(page);
|
||||
await clearPadContent(page);
|
||||
|
||||
39
src/tests/frontend-new/specs/select_focus_restore.spec.ts
Normal file
39
src/tests/frontend-new/specs/select_focus_restore.spec.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {getPadBody, goToNewPad} from '../helper/padHelper';
|
||||
|
||||
test.beforeEach(async ({page}) => {
|
||||
await goToNewPad(page);
|
||||
});
|
||||
|
||||
test('toolbar select change returns focus to the pad editor (#7589)', async ({page}) => {
|
||||
// Regression: after picking a value from a toolbar select (ep_headings
|
||||
// style picker is the canonical example), the caret should return to
|
||||
// the pad editor so typing continues instead of being swallowed by
|
||||
// the select wrapper.
|
||||
const hs = page.locator('#heading-selection');
|
||||
if ((await hs.count()) === 0) {
|
||||
test.skip(true, 'ep_headings2 not enabled in this environment');
|
||||
return;
|
||||
}
|
||||
|
||||
const padBody = await getPadBody(page);
|
||||
await padBody.click();
|
||||
await page.keyboard.type('before');
|
||||
|
||||
// Change the heading style. The native <select> is hidden behind the
|
||||
// nice-select wrapper, which on option click does `val(x).trigger('change')`
|
||||
// internally (see src/static/js/vendors/nice-select.ts). Replicate that
|
||||
// directly rather than trying to click through the wrapper UI.
|
||||
await hs.evaluate((el: HTMLSelectElement) => {
|
||||
el.value = '0';
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
});
|
||||
|
||||
// After the change, keyboard input should go into the pad, not the
|
||||
// toolbar. Write a marker and verify both chunks appear in the pad.
|
||||
await page.keyboard.type('after');
|
||||
await page.waitForTimeout(200);
|
||||
const bodyText = await padBody.innerText();
|
||||
expect(bodyText).toContain('before');
|
||||
expect(bodyText).toContain('after');
|
||||
});
|
||||
104
src/tests/frontend-new/specs/undo_redo_scroll.spec.ts
Normal file
104
src/tests/frontend-new/specs/undo_redo_scroll.spec.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {clearPadContent, getPadBody, goToNewPad} from "../helper/padHelper";
|
||||
|
||||
test.beforeEach(async ({page}) => {
|
||||
await goToNewPad(page);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/ether/etherpad/issues/7007
|
||||
//
|
||||
// Pre-fix: after undo on a large pad, the viewport did not scroll to
|
||||
// follow the caret. When the caret landed below the current viewport,
|
||||
// src/static/js/scroll.ts's caretIsBelowOfViewport branch ran
|
||||
// `outer.scrollTo(0, outer[0].innerHeight)` — a fixed offset, not the
|
||||
// caret position — so the user couldn't see what had just been
|
||||
// modified. That special-case was intended for "Enter at the very end
|
||||
// of the pad" (PR #4639); it misbehaved whenever undo/redo or another
|
||||
// path moved the caret to an arbitrary line below the viewport.
|
||||
test.describe('Undo scroll-to-caret (#7007)', function () {
|
||||
test.describe.configure({retries: 2});
|
||||
|
||||
// Use the Etherpad keyboard path so the undo module has real
|
||||
// changesets to replay. 45 lines is enough to push the pad well past
|
||||
// a typical CI headless viewport (~900px × ~20px per line).
|
||||
const LINE_COUNT = 45;
|
||||
|
||||
test('Ctrl+Z scrolls viewport up when the caret lands above the view', async function ({page}) {
|
||||
await (await getPadBody(page)).click();
|
||||
await clearPadContent(page);
|
||||
|
||||
// Type LINE_COUNT short lines through the real editor (so every line
|
||||
// lands in a changeset the undo module can reverse).
|
||||
for (let i = 0; i < LINE_COUNT; i++) {
|
||||
await page.keyboard.type(`line ${i + 1}`);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Move caret to the top, insert a single edit the undo will reverse.
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.up('Control');
|
||||
await page.keyboard.type('X');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Scroll the outer frame all the way down so the edit is out of view.
|
||||
const outerFrame = page.frame('ace_outer')!;
|
||||
await outerFrame.evaluate(() => {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const scrollBefore = await outerFrame.evaluate(
|
||||
() => window.scrollY || document.scrollingElement?.scrollTop || 0);
|
||||
expect(scrollBefore).toBeGreaterThan(0); // sanity: viewport actually scrolled
|
||||
|
||||
// Undo — caret returns to the top, viewport should follow.
|
||||
await page.keyboard.press('Control+Z');
|
||||
// scrollNodeVerticallyIntoView's caret-below branch uses a 150ms
|
||||
// setTimeout; give it a generous budget for CI.
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const scrollAfter = await outerFrame.evaluate(
|
||||
() => window.scrollY || document.scrollingElement?.scrollTop || 0);
|
||||
|
||||
// Pre-fix: scrollAfter ≈ scrollBefore (no scroll).
|
||||
// Fixed: scrollAfter < scrollBefore (viewport moved up toward the caret).
|
||||
expect(scrollAfter).toBeLessThan(scrollBefore);
|
||||
});
|
||||
|
||||
test('Ctrl+Z scrolls viewport down when the caret lands below the view', async function ({page}) {
|
||||
await (await getPadBody(page)).click();
|
||||
await clearPadContent(page);
|
||||
|
||||
for (let i = 0; i < LINE_COUNT; i++) {
|
||||
await page.keyboard.type(`line ${i + 1}`);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Caret is already at the bottom (after the last Enter). Type an
|
||||
// edit there, then scroll to top.
|
||||
await page.keyboard.type('Y');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const outerFrame = page.frame('ace_outer')!;
|
||||
await outerFrame.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const scrollBefore = await outerFrame.evaluate(
|
||||
() => window.scrollY || document.scrollingElement?.scrollTop || 0);
|
||||
expect(scrollBefore).toBe(0);
|
||||
|
||||
await page.keyboard.press('Control+Z');
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const scrollAfter = await outerFrame.evaluate(
|
||||
() => window.scrollY || document.scrollingElement?.scrollTop || 0);
|
||||
|
||||
// Pre-fix: scrollAfter was pinned to outer[0].innerHeight (a fixed
|
||||
// offset) or stayed at 0; either way it was not the caret location.
|
||||
// Fixed: the viewport scrolls down toward the caret at the bottom.
|
||||
expect(scrollAfter).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user