Merge branch 'develop'

This commit is contained in:
Etherpad Release Bot 2026-04-26 09:35:14 +00:00
commit f1000e20fc
45 changed files with 1677 additions and 476 deletions

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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()

View File

@ -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()

View File

@ -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') }}

View File

@ -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

View File

@ -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

View File

@ -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: |

View File

@ -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: |

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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": {

View File

@ -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 () {

View File

@ -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,
},
},
},
})

View File

@ -1,6 +1,6 @@
{
"name": "bin",
"version": "2.7.1",
"version": "2.7.2",
"description": "",
"main": "checkAllPads.js",
"directories": {

View 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.

View File

@ -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
View File

@ -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

View File

@ -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);

View File

@ -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,

View File

@ -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"
}

View File

@ -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

View File

@ -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 {

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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) {

View File

@ -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

View File

@ -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;
}

View File

@ -26,7 +26,7 @@ table#otheruserstable {
}
#myusernameform {
margin-left: 35px;
margin-left: 10px;
}
input#myusernameedit {

View File

@ -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">

View File

@ -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;">-&nbsp;</a>
<a id="titlesticky" class="stick-to-screen-btn" onClick="chat.stickToScreen(true);return false;" data-l10n-id="pad.chat.stick.title">&nbsp;&nbsp;</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">&#9608;</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" ]; %>

View File

@ -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>

View File

@ -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);
});
});
});

View 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 });
});
});

View 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');
});

View File

@ -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);
});

View File

@ -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
// &minus; 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();
});

View File

@ -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();
});
});

View File

@ -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);

View 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');
});

View 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);
});
});