Compare commits

..

2 Commits

Author SHA1 Message Date
Robin
569d525c6e Wait for a close action before closing a call
This creates a distinction between the user hanging up and the widget being ready to close, which is useful for allowing Element Call to show error screens when disconnected from the call, for example.
2025-02-20 13:54:10 +07:00
Robin
7799cb2ec5 Avoid destroying calls until they are hidden from the UI
We often want calls to exist even when no more participants are left in the MatrixRTC session. So, we should avoid destroying calls as long as they're being presented in the UI; this means that the user has an intent to either join the call or continue looking at an error screen, and we shouldn't interrupt that interaction.

The RoomViewStore is now what takes care of creating and destroying calls, rather than the CallView. In general it seems kinda impossible to safely create and destroy model objects from React lifecycle hooks, so moving this responsibility to a store seemed appropriate and resolves existing issues with calls in React strict mode.
2025-02-20 13:54:10 +07:00
821 changed files with 10883 additions and 22830 deletions

View File

@@ -8,6 +8,3 @@ src/component-index.js
# Auto-generated file
src/modules.ts
src/modules.js
# Test result files
/playwright/test-results/
/playwright/html-report/

View File

@@ -11,7 +11,7 @@ runs:
using: composite
steps:
- name: Download release tarball
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1
uses: robinraju/release-downloader@a96f54c1b5f5e09e47d9504526e96febd949d4c2 # v1
with:
tag: ${{ inputs.tag }}
fileName: element-*.tar.gz*

View File

@@ -26,6 +26,12 @@ jobs:
R2_URL: ${{ vars.CF_R2_S3_API }}
R2_PUBLIC_URL: "https://element-web-develop.element.io"
steps:
# Workaround for https://www.cloudflarestatus.com/incidents/t5nrjmpxc1cj
- uses: unfor19/install-aws-cli-action@v1
with:
version: 2.22.35
verbose: false
arch: amd64
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -109,11 +115,10 @@ jobs:
# We keep the latest develop.tar.gz on R2 instead of relying on the github artifact uploaded earlier
# as the expires after 24h and requires auth to download.
# Element Desktop's fetch script uses this tarball to fetch latest develop to build Nightlies.
# Checksum algorithm specified as per https://developers.cloudflare.com/r2/examples/aws/aws-cli/
- name: Deploy to R2
run: |
aws s3 cp dist/develop.tar.gz s3://$R2_BUCKET/develop.tar.gz --endpoint-url $R2_URL --region=auto --checksum-algorithm CRC32
aws s3 cp _deploy/ s3://$R2_BUCKET/ --recursive --endpoint-url $R2_URL --region=auto --checksum-algorithm CRC32
aws s3 cp dist/develop.tar.gz s3://$R2_BUCKET/develop.tar.gz --endpoint-url $R2_URL --region=auto
aws s3 cp _deploy/ s3://$R2_BUCKET/ --recursive --endpoint-url $R2_URL --region=auto
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_TOKEN }}

View File

@@ -3,7 +3,6 @@ on:
workflow_dispatch: {}
push:
tags: [v*]
pull_request: {}
schedule:
# This job can take a while, and we have usage limits, so just publish develop only twice a day
- cron: "0 7/12 * * *"
@@ -13,91 +12,42 @@ jobs:
buildx:
name: Docker Buildx
runs-on: ubuntu-24.04
environment: ${{ github.event_name != 'pull_request' && 'dockerhub' || '' }}
environment: dockerhub
permissions:
id-token: write # needed for signing the images with GitHub OIDC Token
packages: write # needed for publishing packages to GHCR
env:
TEST_TAG: vectorim/element-web:test
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for docker-package to be able to calculate the version
- name: Install Cosign
uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e # v3
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
uses: docker/setup-qemu-action@4574d27a4764455b42196d70a065bc6853246a25 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3
with:
install: true
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
if: github.event_name != 'pull_request'
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
if: github.event_name != 'pull_request'
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and load
id: test-build
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
with:
context: .
load: true
- name: Test the image
env:
IMAGEID: ${{ steps.test-build.outputs.imageid }}
timeout-minutes: 2
run: |
set -x
# Make a fake module to test the image
MODULE_PATH="modules/module_name/index.js"
mkdir -p $(dirname $MODULE_PATH)
echo 'alert("Testing");' > $MODULE_PATH
# Spin up a container of the image
ELEMENT_WEB_PORT=8181
CONTAINER_ID=$(
docker run \
--rm \
-e "ELEMENT_WEB_PORT=$ELEMENT_WEB_PORT" \
-dp "$ELEMENT_WEB_PORT:$ELEMENT_WEB_PORT" \
-v $(pwd)/modules:/modules \
"$IMAGEID" \
)
# Run some smoke tests
wget --retry-connrefused --tries=5 -q --wait=3 --spider "http://localhost:$ELEMENT_WEB_PORT/modules/module_name/index.js"
MODULE_0=$(curl "http://localhost:$ELEMENT_WEB_PORT/config.json" | jq -r .modules[0])
test "$MODULE_0" = "/${MODULE_PATH}"
# Check healthcheck
until test "$(docker inspect -f {{.State.Health.Status}} $CONTAINER_ID)" == "healthy"; do
sleep 1
done
# Clean up
docker stop "$CONTAINER_ID"
- name: Docker meta
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
if: github.event_name != 'pull_request'
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5
with:
images: |
vectorim/element-web
@@ -110,8 +60,7 @@ jobs:
- name: Build and push
id: build-and-push
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
if: github.event_name != 'pull_request'
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
with:
context: .
push: true
@@ -123,7 +72,6 @@ jobs:
env:
DIGEST: ${{ steps.build-and-push.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
if: github.event_name != 'pull_request'
run: |
images=""
for tag in ${TAGS}; do
@@ -132,8 +80,7 @@ jobs:
cosign sign --yes ${images}
- name: Update repo description
uses: peter-evans/dockerhub-description@0505d8b04853a30189aee66f5bb7fd1511bbac71 # v4
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
continue-on-error: true
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}

View File

@@ -23,7 +23,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/playwright-image-updates

View File

@@ -19,7 +19,6 @@ jobs:
contents: write
issues: write
pull-requests: read
id-token: write
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}

View File

@@ -51,7 +51,6 @@ jobs:
error|invalid_json
error|misconfigured
welcome_to_element
devtools|settings|elementCallUrl
rethemendex_lint:
name: "Rethemendex Check"

View File

@@ -104,7 +104,7 @@ jobs:
- name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
uses: guibranco/github-status-action-v2@9b1d102b3c32583174557f58c53e3b09d43d1b1d
uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success

View File

@@ -23,7 +23,7 @@ jobs:
run: "yarn update:jitsi"
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/jitsi-update

View File

@@ -1,105 +1,3 @@
Changes in [1.11.96](https://github.com/element-hq/element-web/releases/tag/v1.11.96) (2025-03-25)
==================================================================================================
## ✨ Features
* RoomListViewModel: Track the index of the active room in the list ([#29519](https://github.com/element-hq/element-web/pull/29519)). Contributed by @MidhunSureshR.
* New room list: add empty state ([#29512](https://github.com/element-hq/element-web/pull/29512)). Contributed by @florianduros.
* Implement `MessagePreviewViewModel` ([#29514](https://github.com/element-hq/element-web/pull/29514)). Contributed by @MidhunSureshR.
* RoomListViewModel: Add functionality to toggle message preview setting ([#29511](https://github.com/element-hq/element-web/pull/29511)). Contributed by @MidhunSureshR.
* New room list: add more options menu on room list item ([#29445](https://github.com/element-hq/element-web/pull/29445)). Contributed by @florianduros.
* RoomListViewModel: Provide a way to resort the room list and track the active sort method ([#29499](https://github.com/element-hq/element-web/pull/29499)). Contributed by @MidhunSureshR.
* Change \*All rooms\* meta space name to \*All Chats\* ([#29498](https://github.com/element-hq/element-web/pull/29498)). Contributed by @florianduros.
* Add setting to hide avatars of rooms you have been invited to. ([#29497](https://github.com/element-hq/element-web/pull/29497)). Contributed by @Half-Shot.
* Room List Store: Save preferred sorting algorithm and use that on app launch ([#29493](https://github.com/element-hq/element-web/pull/29493)). Contributed by @MidhunSureshR.
* Add key storage toggle to Encryption settings ([#29310](https://github.com/element-hq/element-web/pull/29310)). Contributed by @dbkr.
* New room list: add primary filters ([#29481](https://github.com/element-hq/element-web/pull/29481)). Contributed by @florianduros.
* Implement MSC4142: Remove unintentional intentional mentions in replies ([#28209](https://github.com/element-hq/element-web/pull/28209)). Contributed by @tulir.
* White background for 'They do not match' button ([#29470](https://github.com/element-hq/element-web/pull/29470)). Contributed by @andybalaam.
* RoomListViewModel: Support secondary filters in the view model ([#29465](https://github.com/element-hq/element-web/pull/29465)). Contributed by @MidhunSureshR.
* RoomListViewModel: Support primary filters in the view model ([#29454](https://github.com/element-hq/element-web/pull/29454)). Contributed by @MidhunSureshR.
* Room List Store: Implement secondary filters ([#29458](https://github.com/element-hq/element-web/pull/29458)). Contributed by @MidhunSureshR.
* Room List Store: Implement rest of the primary filters ([#29444](https://github.com/element-hq/element-web/pull/29444)). Contributed by @MidhunSureshR.
* Room List Store: Support filters by implementing just the favourite filter ([#29433](https://github.com/element-hq/element-web/pull/29433)). Contributed by @MidhunSureshR.
* Move toggle switch for integration manager for a11y ([#29436](https://github.com/element-hq/element-web/pull/29436)). Contributed by @Half-Shot.
* New room list: basic flat list ([#29368](https://github.com/element-hq/element-web/pull/29368)). Contributed by @florianduros.
* Improve rageshake upload experience by providing useful error information ([#29378](https://github.com/element-hq/element-web/pull/29378)). Contributed by @Half-Shot.
* Add more functionality to the room list vm ([#29402](https://github.com/element-hq/element-web/pull/29402)). Contributed by @MidhunSureshR.
## 🐛 Bug Fixes
* New room list: fix compose menu action in space ([#29500](https://github.com/element-hq/element-web/pull/29500)). Contributed by @florianduros.
* Change ToggleHiddenEventVisibility \& GoToHome KeyBindingActions ([#29374](https://github.com/element-hq/element-web/pull/29374)). Contributed by @gy-mate.
* Fix Docker Healthcheck ([#29471](https://github.com/element-hq/element-web/pull/29471)). Contributed by @benbz.
* Room List Store: Fetch rooms after space store is ready + attach store to window ([#29453](https://github.com/element-hq/element-web/pull/29453)). Contributed by @MidhunSureshR.
* Room List Store: Fix bug where left rooms appear in room list ([#29452](https://github.com/element-hq/element-web/pull/29452)). Contributed by @MidhunSureshR.
* Add space to the bottom of the room summary actions below leave room ([#29270](https://github.com/element-hq/element-web/pull/29270)). Contributed by @langleyd.
* Show error screens in group calls ([#29254](https://github.com/element-hq/element-web/pull/29254)). Contributed by @robintown.
* Prevent user from accidentally triggering multiple identity resets ([#29388](https://github.com/element-hq/element-web/pull/29388)). Contributed by @uhoreg.
* Remove buggy tooltip on room intro \& homepage ([#29406](https://github.com/element-hq/element-web/pull/29406)). Contributed by @t3chguy.
Changes in [1.11.95](https://github.com/element-hq/element-web/releases/tag/v1.11.95) (2025-03-11)
==================================================================================================
## ✨ Features
* Room List Store: Filter rooms by active space ([#29399](https://github.com/element-hq/element-web/pull/29399)). Contributed by @MidhunSureshR.
* Room List - Update the room list store on actions from the dispatcher ([#29397](https://github.com/element-hq/element-web/pull/29397)). Contributed by @MidhunSureshR.
* Room List - Implement a minimal view model ([#29357](https://github.com/element-hq/element-web/pull/29357)). Contributed by @MidhunSureshR.
* New room list: add space menu in room header ([#29352](https://github.com/element-hq/element-web/pull/29352)). Contributed by @florianduros.
* Room List - Store sorted rooms in skip list ([#29345](https://github.com/element-hq/element-web/pull/29345)). Contributed by @MidhunSureshR.
* New room list: add dial to search section ([#29359](https://github.com/element-hq/element-web/pull/29359)). Contributed by @florianduros.
* New room list: add compose menu for spaces in header ([#29347](https://github.com/element-hq/element-web/pull/29347)). Contributed by @florianduros.
* Use EditInPlace control for Identity Server picker to improve a11y ([#29280](https://github.com/element-hq/element-web/pull/29280)). Contributed by @Half-Shot.
* First step to add header to new room list ([#29320](https://github.com/element-hq/element-web/pull/29320)). Contributed by @florianduros.
* Add Windows 64-bit arm link and remove 32-bit link on compatibility page ([#29312](https://github.com/element-hq/element-web/pull/29312)). Contributed by @t3chguy.
* Honour the backup disable flag from Element X ([#29290](https://github.com/element-hq/element-web/pull/29290)). Contributed by @dbkr.
## 🐛 Bug Fixes
* Fix edited code block width ([#29394](https://github.com/element-hq/element-web/pull/29394)). Contributed by @florianduros.
* new room list: keep space name in one line in header ([#29369](https://github.com/element-hq/element-web/pull/29369)). Contributed by @florianduros.
* Dismiss "Key storage out of sync" toast when secrets received ([#29348](https://github.com/element-hq/element-web/pull/29348)). Contributed by @richvdh.
* Minor CSS fixes for the new room list ([#29334](https://github.com/element-hq/element-web/pull/29334)). Contributed by @florianduros.
* Add padding to room header icon ([#29271](https://github.com/element-hq/element-web/pull/29271)). Contributed by @langleyd.
Changes in [1.11.94](https://github.com/element-hq/element-web/releases/tag/v1.11.94) (2025-02-27)
==================================================================================================
## 🐛 Bug Fixes
* [Backport staging] fix: /tmp/element-web-config may already exist preventing the container from booting up ([#29377](https://github.com/element-hq/element-web/pull/29377)). Contributed by @RiotRobot.
Changes in [1.11.93](https://github.com/element-hq/element-web/releases/tag/v1.11.93) (2025-02-25)
==================================================================================================
## ✨ Features
* [backport] Dynamically load Element Web modules in Docker entrypoint ([#29358](https://github.com/element-hq/element-web/pull/29358)). Contributed by @t3chguy.
* ChangeRecoveryKey: error handling ([#29262](https://github.com/element-hq/element-web/pull/29262)). Contributed by @richvdh.
* Dehydration: enable dehydrated device on "Set up recovery" ([#29265](https://github.com/element-hq/element-web/pull/29265)). Contributed by @richvdh.
* Render reason for invite rejection. ([#29257](https://github.com/element-hq/element-web/pull/29257)). Contributed by @Half-Shot.
* New room list: add search section ([#29251](https://github.com/element-hq/element-web/pull/29251)). Contributed by @florianduros.
* New room list: hide favourites and people meta spaces ([#29241](https://github.com/element-hq/element-web/pull/29241)). Contributed by @florianduros.
* New Room List: Create new labs flag ([#29239](https://github.com/element-hq/element-web/pull/29239)). Contributed by @MidhunSureshR.
* Stop URl preview from covering message box ([#29215](https://github.com/element-hq/element-web/pull/29215)). Contributed by @edent.
* Rename "security key" into "recovery key" ([#29217](https://github.com/element-hq/element-web/pull/29217)). Contributed by @florianduros.
* Add new verification section to user profile ([#29200](https://github.com/element-hq/element-web/pull/29200)). Contributed by @MidhunSureshR.
* Initial support for runtime modules ([#29104](https://github.com/element-hq/element-web/pull/29104)). Contributed by @t3chguy.
* Add `Forgot recovery key?` button to encryption tab ([#29202](https://github.com/element-hq/element-web/pull/29202)). Contributed by @florianduros.
* Add KeyIcon to key storage out of sync toast ([#29201](https://github.com/element-hq/element-web/pull/29201)). Contributed by @florianduros.
* Improve rendering of empty topics in the timeline ([#29152](https://github.com/element-hq/element-web/pull/29152)). Contributed by @Half-Shot.
## 🐛 Bug Fixes
* Fix font scaling in member list ([#29285](https://github.com/element-hq/element-web/pull/29285)). Contributed by @florianduros.
* Grow member list search field when resizing the right panel ([#29267](https://github.com/element-hq/element-web/pull/29267)). Contributed by @langleyd.
* Don't reload roomview on offline connectivity check ([#29243](https://github.com/element-hq/element-web/pull/29243)). Contributed by @dbkr.
* Respect user's 12/24 hour preference consistently ([#29237](https://github.com/element-hq/element-web/pull/29237)). Contributed by @t3chguy.
* Restore the accessibility role on call views ([#29225](https://github.com/element-hq/element-web/pull/29225)). Contributed by @robintown.
* Revert `GoToHome` keyboard shortcut to `Ctrl``Shift``H` on macOS ([#28577](https://github.com/element-hq/element-web/pull/28577)). Contributed by @gy-mate.
* Encryption tab: display correct encryption panel when user cancels the reset identity flow ([#29216](https://github.com/element-hq/element-web/pull/29216)). Contributed by @florianduros.
Changes in [1.11.92](https://github.com/element-hq/element-web/releases/tag/v1.11.92) (2025-02-11)
==================================================================================================
## ✨ Features

View File

@@ -1,5 +1,3 @@
# syntax=docker.io/docker/dockerfile:1.14-labs
# Builder
FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder
@@ -10,7 +8,7 @@ ARG JS_SDK_BRANCH="master"
WORKDIR /src
COPY --exclude=docker . /src
COPY . /src
RUN /src/scripts/docker-link-repos.sh
RUN yarn --network-timeout=200000 install
RUN /src/scripts/docker-package.sh
@@ -19,20 +17,20 @@ RUN /src/scripts/docker-package.sh
RUN cp /src/config.sample.json /src/webapp/config.json
# App
FROM nginxinc/nginx-unprivileged:alpine-slim
# Need root user to install packages & manipulate the usr directory
USER root
# Install jq and moreutils for sponge, both used by our entrypoints
RUN apk add jq moreutils
FROM nginx:alpine-slim
COPY --from=builder /src/webapp /app
# Override default nginx config. Templates in `/etc/nginx/templates` are passed
# through `envsubst` by the nginx docker image entry point.
COPY /docker/nginx-templates/* /etc/nginx/templates/
COPY /docker/docker-entrypoint.d/* /docker-entrypoint.d/
# Tell nginx to put its pidfile elsewhere, so it can run as non-root
RUN sed -i -e 's,/var/run/nginx.pid,/tmp/nginx.pid,' /etc/nginx/nginx.conf
# nginx user must own the cache and etc directory to write cache and tweak the nginx config
RUN chown -R nginx:0 /var/cache/nginx /etc/nginx
RUN chmod -R g+w /var/cache/nginx /etc/nginx
RUN rm -rf /usr/share/nginx/html \
&& ln -s /app /usr/share/nginx/html
@@ -42,5 +40,3 @@ USER nginx
# HTTP listen port
ENV ELEMENT_WEB_PORT=80
HEALTHCHECK --start-period=5s CMD wget -q --spider http://localhost:$ELEMENT_WEB_PORT/config.json

View File

@@ -31,7 +31,5 @@ module.exports = {
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-runtime",
["@babel/plugin-proposal-decorators", { version: "2023-11" }], // only needed by the js-sdk
"@babel/plugin-transform-class-static-block", // only needed by the js-sdk for decorators
],
};

View File

@@ -1,34 +0,0 @@
#!/bin/sh
# Loads modules from `/modules` into config.json's `modules` field
set -e
entrypoint_log() {
if [ -z "${NGINX_ENTRYPOINT_QUIET_LOGS:-}" ]; then
echo "$@"
fi
}
# Copy these config files as a base
mkdir -p /tmp/element-web-config
cp /app/config*.json /tmp/element-web-config/
# If there are modules to be loaded
if [ -d "/modules" ]; then
cd /modules
for MODULE in *
do
# If the module has a package.json, use its main field as the entrypoint
ENTRYPOINT="index.js"
if [ -f "/modules/$MODULE/package.json" ]; then
ENTRYPOINT=$(jq -r '.main' "/modules/$MODULE/package.json")
fi
entrypoint_log "Loading module $MODULE with entrypoint $ENTRYPOINT"
# Append the module to the config
jq ".modules += [\"/modules/$MODULE/$ENTRYPOINT\"]" /tmp/element-web-config/config.json | sponge /tmp/element-web-config/config.json
done
fi

View File

@@ -18,12 +18,8 @@ server {
}
# covers config.json and config.hostname.json requests as it is prefix.
location /config {
root /tmp/element-web-config;
add_header Cache-Control "no-cache";
}
location /modules {
alias /modules;
}
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;

View File

@@ -46,6 +46,7 @@
- [Skinning](skinning.md)
- [Cider editor](ciderEditor.md)
- [Iconography](icons.md)
- [Jitsi](jitsi.md)
- [Local echo](local-echo-dev.md)
- [Media](media-handling.md)
- [Room List Store](room-list-store.md)

View File

@@ -384,6 +384,8 @@ The VoIP and Jitsi options are:
5. `audio_stream_url`: Optional URL to pass to Jitsi to enable live streaming. This option is considered experimental and may be removed
at any time without notice.
6. `element_call`: Optional configuration for native group calls using Element Call, with the following subkeys:
- `url`: The URL of the Element Call instance to use for native group calls. This option is considered experimental
and may be removed at any time without notice. Defaults to `https://call.element.io`.
- `use_exclusively`: A boolean specifying whether Element Call should be used exclusively as the only VoIP stack in
the app, removing the ability to start legacy 1:1 calls or Jitsi calls. Defaults to `false`.
- `participant_limit`: The maximum number of users who can join a call; if

View File

@@ -66,18 +66,6 @@ on other runtimes may require root privileges. To resolve this, either run the
image as root (`docker run --user 0`) or, better, change the port that nginx
listens on via the `ELEMENT_WEB_PORT` environment variable.
[Element Web Modules](https://github.com/element-hq/element-modules/tree/main/packages/element-web-module-api) can be dynamically loaded
by being made available (e.g. via bind mount) in a directory within `/modules/`.
The default entrypoint will be index.js in that directory but can be overridden if a package.json file is found with a `main` directive.
These modules will be presented in a `/modules` subdirectory within the webroot, and automatically added to the config.json `modules` field.
If you wish to use docker in read-only mode,
you should follow the [upstream instructions](https://hub.docker.com/_/nginx#:~:text=Running%20nginx%20in%20read%2Donly%20mode)
but additionally include the following directories:
- /tmp/
- /etc/nginx/conf.d/
The behaviour of the docker image can be customised via the following
environment variables:

View File

@@ -19,7 +19,6 @@ export default {
ignore: [
// Keep for now
"src/hooks/useLocalStorageState.ts",
"src/hooks/useTimeout.ts",
"src/components/views/elements/InfoTooltip.tsx",
"src/components/views/elements/StyledCheckbox.tsx",
],
@@ -40,8 +39,6 @@ export default {
// Used by webpack
"process",
"util",
// Embedded into webapp
"@element-hq/element-call-embedded",
],
ignoreBinaries: [
// Used in scripts & workflows

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.96",
"version": "1.11.91",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -62,20 +62,19 @@
"test": "jest",
"test:playwright": "playwright test",
"test:playwright:open": "yarn test:playwright --ui",
"test:playwright:screenshots": "playwright-screenshots --project=Chrome",
"test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run",
"test:playwright:screenshots:build": "docker build playwright -t element-web-playwright",
"test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -e CI -v $(pwd):/work/ -v $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright --grep @screenshot --project=Chrome",
"coverage": "yarn test --coverage",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js",
"postinstall": "patch-package"
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js"
},
"resolutions": {
"@playwright/test": "1.51.1",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"oidc-client-ts": "3.2.0",
"oidc-client-ts": "3.1.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001707",
"testcontainers": "10.21.0",
"caniuse-lite": "1.0.30001699",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
@@ -85,16 +84,16 @@
"@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",
"@matrix-org/analytics-events": "^0.29.2",
"@matrix-org/analytics-events": "^0.29.0",
"@matrix-org/emojibase-bindings": "^1.3.4",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^9.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^7.10.0",
"@vector-im/matrix-wysiwyg": "2.38.3",
"@vector-im/compound-design-tokens": "^3.0.0",
"@vector-im/compound-web": "^7.6.1",
"@vector-im/matrix-wysiwyg": "2.38.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
@@ -108,7 +107,6 @@
"css-tree": "^3.0.0",
"diff-dom": "^5.0.0",
"diff-match-patch": "^1.0.5",
"domutils": "^3.2.2",
"emojibase-regex": "15.3.2",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
@@ -117,12 +115,12 @@
"glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1",
"html-entities": "^2.0.0",
"html-react-parser": "^5.2.2",
"is-ip": "^3.1.0",
"js-xxhash": "^4.0.0",
"jsrsasign": "^11.0.0",
"jszip": "^3.7.0",
"katex": "^0.16.0",
"linkify-element": "4.2.0",
"linkify-react": "4.2.0",
"linkify-string": "4.2.0",
"linkifyjs": "4.2.0",
@@ -140,20 +138,19 @@
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.157.2",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"re-resizable": "6.10.3",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.0",
"react-blurhash": "^0.3.0",
"react-dom": "^18.3.1",
"react-focus-lock": "^2.5.1",
"react-string-replace": "^1.1.1",
"react-transition-group": "^4.4.1",
"react-virtualized": "^9.22.5",
"rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3",
"sanitize-html": "2.15.0",
"sanitize-html": "2.14.0",
"tar-js": "^0.3.0",
"temporal-polyfill": "^0.3.0",
"temporal-polyfill": "^0.2.5",
"ua-parser-js": "^1.0.2",
"uuid": "^11.0.0",
"what-input": "^5.2.10"
@@ -161,14 +158,13 @@
"devDependencies": {
"@action-validator/cli": "^0.6.0",
"@action-validator/core": "^0.6.0",
"@axe-core/playwright": "^4.8.1",
"@babel/core": "^7.12.10",
"@babel/eslint-parser": "^7.12.10",
"@babel/eslint-plugin": "^7.12.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-proposal-export-default-from": "^7.12.1",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-class-properties": "^7.12.1",
"@babel/plugin-transform-class-static-block": "^7.26.0",
"@babel/plugin-transform-logical-assignment-operators": "^7.20.7",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.12.1",
"@babel/plugin-transform-numeric-separator": "^7.12.7",
@@ -180,14 +176,13 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-call-embedded": "0.9.0",
"@element-hq/element-web-playwright-common": "^1.1.5",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1",
"@playwright/test": "^1.40.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@sentry/webpack-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^3.0.0",
"@svgr/webpack": "^8.0.0",
"@testcontainers/postgresql": "^10.16.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
@@ -215,7 +210,7 @@
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "18.3.5",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.15.0",
"@types/sanitize-html": "2.13.0",
"@types/semver": "^7.5.8",
"@types/tar-js": "^0.3.5",
"@types/ua-parser-js": "^0.7.36",
@@ -223,12 +218,12 @@
"@typescript-eslint/eslint-plugin": "^8.19.0",
"@typescript-eslint/parser": "^8.19.0",
"babel-jest": "^29.0.0",
"babel-loader": "^10.0.0",
"babel-loader": "^9.0.0",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"blob-polyfill": "^9.0.0",
"chokidar": "^4.0.0",
"concurrently": "^9.0.0",
"copy-webpack-plugin": "^13.0.0",
"copy-webpack-plugin": "^12.0.0",
"core-js": "^3.38.1",
"cronstrue": "^2.41.0",
"css-loader": "^7.0.0",
@@ -262,13 +257,13 @@
"jsqr": "^1.4.0",
"knip": "^5.36.2",
"lint-staged": "^15.0.2",
"mailpit-api": "^1.0.5",
"matrix-web-i18n": "^3.2.1",
"mini-css-extract-plugin": "2.9.2",
"minimist": "^1.2.6",
"modernizr": "^3.12.0",
"node-fetch": "^2.6.7",
"patch-package": "^8.0.0",
"playwright-core": "^1.51.0",
"playwright-core": "^1.45.1",
"postcss": "8.4.46",
"postcss-easings": "^4.0.0",
"postcss-hexrgba": "2.1.0",
@@ -279,20 +274,21 @@
"postcss-preset-env": "^10.0.0",
"postcss-scss": "^4.0.4",
"postcss-simple-vars": "^7.0.1",
"prettier": "3.5.3",
"prettier": "3.5.1",
"process": "^0.11.10",
"raw-loader": "^4.0.2",
"rimraf": "^6.0.0",
"semver": "^7.5.2",
"source-map-loader": "^5.0.0",
"strip-ansi": "^7.1.0",
"stylelint": "^16.13.0",
"stylelint-config-standard": "^37.0.0",
"stylelint-scss": "^6.0.0",
"stylelint-value-no-unknown-custom-properties": "^6.0.1",
"terser-webpack-plugin": "^5.3.9",
"testcontainers": "^10.20.0",
"testcontainers": "^10.16.0",
"ts-node": "^10.9.1",
"typescript": "5.8.2",
"typescript": "5.7.3",
"util": "^0.12.5",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",

View File

@@ -1,13 +0,0 @@
diff --git a/node_modules/@matrix-org/react-sdk-module-api/lib/ModuleApi.d.ts b/node_modules/@matrix-org/react-sdk-module-api/lib/ModuleApi.d.ts
index 917a7fc..a2710c6 100644
--- a/node_modules/@matrix-org/react-sdk-module-api/lib/ModuleApi.d.ts
+++ b/node_modules/@matrix-org/react-sdk-module-api/lib/ModuleApi.d.ts
@@ -37,7 +37,7 @@ export interface ModuleApi {
* @returns Whether the user submitted the dialog or closed it, and the model returned by the
* dialog component if submitted.
*/
- openDialog<M extends object, P extends DialogProps = DialogProps, C extends DialogContent<P> = DialogContent<P>>(initialTitleOrOptions: string | ModuleUiDialogOptions, body: (props: P, ref: React.RefObject<C>) => React.ReactNode, props?: Omit<P, keyof DialogProps>): Promise<{
+ openDialog<M extends object, P extends DialogProps = DialogProps, C extends DialogContent<P> = DialogContent<P>>(initialTitleOrOptions: string | ModuleUiDialogOptions, body: (props: P, ref: React.RefObject<C | null>) => React.ReactNode, props?: Omit<P, keyof DialogProps>): Promise<{
didOkOrSubmit: boolean;
model: M;
}>;

View File

@@ -1,76 +0,0 @@
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
index 6ea73ef..cb51757 100644
--- a/node_modules/@types/react/index.d.ts
+++ b/node_modules/@types/react/index.d.ts
@@ -151,7 +151,7 @@ declare namespace React {
/**
* The current value of the ref.
*/
- readonly current: T | null;
+ current: T;
}
interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES {
@@ -186,7 +186,7 @@ declare namespace React {
* @see {@link RefObject}
*/
- type Ref<T> = RefCallback<T> | RefObject<T> | null;
+ type Ref<T> = RefCallback<T> | RefObject<T | null> | null;
/**
* A legacy implementation of refs where you can pass a string to a ref prop.
*
@@ -300,7 +300,7 @@ declare namespace React {
*
* @see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
*/
- ref?: LegacyRef<T> | undefined;
+ ref?: LegacyRef<T | null> | undefined;
}
/**
@@ -1234,7 +1234,7 @@ declare namespace React {
*
* @see {@link ForwardRefRenderFunction}
*/
- type ForwardedRef<T> = ((instance: T | null) => void) | MutableRefObject<T | null> | null;
+ type ForwardedRef<T> = ((instance: T | null) => void) | RefObject<T | null> | null;
/**
* The type of the function passed to {@link forwardRef}. This is considered different
@@ -1565,7 +1565,7 @@ declare namespace React {
[propertyName: string]: any;
}
- function createRef<T>(): RefObject<T>;
+ function createRef<T>(): RefObject<T | null>;
/**
* The type of the component returned from {@link forwardRef}.
@@ -1989,7 +1989,7 @@ declare namespace React {
* @version 16.8.0
* @see {@link https://react.dev/reference/react/useRef}
*/
- function useRef<T>(initialValue: T): MutableRefObject<T>;
+ function useRef<T>(initialValue: T): RefObject<T>;
// convenience overload for refs given as a ref prop as they typically start with a null value
/**
* `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
@@ -2004,7 +2004,7 @@ declare namespace React {
* @version 16.8.0
* @see {@link https://react.dev/reference/react/useRef}
*/
- function useRef<T>(initialValue: T | null): RefObject<T>;
+ function useRef<T>(initialValue: T | null): RefObject<T | null>;
// convenience overload for potentially undefined initialValue / call with 0 arguments
// has a default to stop it from defaulting to {} instead
/**
@@ -2017,7 +2017,7 @@ declare namespace React {
* @version 16.8.0
* @see {@link https://react.dev/reference/react/useRef}
*/
- function useRef<T = undefined>(initialValue?: undefined): MutableRefObject<T | undefined>;
+ function useRef<T>(initialValue: T | undefined): RefObject<T | undefined>;
/**
* The signature is identical to `useEffect`, but it fires synchronously after all DOM mutations.
* Use this to read layout from the DOM and synchronously re-render. Updates scheduled inside

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { defineConfig, devices } from "@playwright/test";
import { type WorkerOptions } from "./playwright/services";
import { Options } from "./playwright/services";
const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080";
@@ -21,7 +21,7 @@ const chromeProject = {
},
};
export default defineConfig<WorkerOptions>({
export default defineConfig<Options>({
projects: [
{
name: "Chrome",
@@ -83,7 +83,6 @@ export default defineConfig<WorkerOptions>({
url: `${baseURL}/config.json`,
reuseExistingServer: true,
timeout: (process.env.CI ? 30 : 120) * 1000,
stdout: "pipe",
},
testDir: "playwright/e2e",
outputDir: "playwright/test-results",

12
playwright/@types/playwright-core.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "playwright-core/lib/utils" {
// This type is not public in playwright-core utils
export function sanitizeForFilePath(filePath: string): string;
}

9
playwright/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM mcr.microsoft.com/playwright:v1.50.1-noble
WORKDIR /work
# fonts-dejavu is needed for the same RTL rendering as on CI
RUN apt-get update && apt-get -y install docker.io fonts-dejavu
COPY docker-entrypoint.sh /opt/docker-entrypoint.sh
ENTRYPOINT ["bash", "/opt/docker-entrypoint.sh"]

View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -e
npx playwright test --update-snapshots --reporter line $@

View File

@@ -28,7 +28,7 @@ const checkDMRoom = async (page: Page) => {
};
const startDMWithBob = async (page: Page, bob: Bot) => {
await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click();
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click();
await expect(
@@ -162,7 +162,6 @@ test.describe("Cryptography", function () {
}
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
const secretStorageKey = await enableKeyBackup(app);
// Fetch the current cross-signing keys

View File

@@ -22,27 +22,33 @@ test.use({
msc3814_enabled: true,
},
},
config: async ({ config, context }, use) => {
const wellKnown = {
...config.default_server_config,
"org.matrix.msc3814": true,
};
await context.route("https://localhost/.well-known/matrix/client", async (route) => {
await route.fulfill({ json: wellKnown });
});
await use(config);
},
});
test.describe("Dehydration", () => {
test.skip(isDendrite, "does not yet support dehydration v2");
test("Verify device and reset creates dehydrated device", async ({ page, user, credentials, app }, workerInfo) => {
// Verify the device by resetting the key (which will create SSSS, and dehydrated device)
test("'Set up secure backup' creates dehydrated device", async ({ page, user, app }, workerInfo) => {
// Create a backup (which will create SSSS, and dehydrated device)
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await app.closeDialog();
// Verify the device by resetting the key
const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Copy" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Done" }).click();
await completeCreateSecretStorageDialog(page);
await expectDehydratedDeviceEnabled(app);
@@ -110,40 +116,6 @@ test.describe("Dehydration", () => {
expect(dehydratedDeviceIds.length).toBe(1);
expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]);
});
test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => {
await logIntoElement(page, credentials);
// Create a dehydrated device by setting up recovery (see "'Set up
// recovery' creates dehydrated device" test above)
const settingsDialogLocator = await app.settings.openUserSettings("Encryption");
await settingsDialogLocator.getByRole("button", { name: "Set up recovery" }).click();
// First it displays an informative panel about the recovery key
await expect(settingsDialogLocator.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
// Next, it displays the new recovery key. We click on the copy button.
await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible();
await settingsDialogLocator.getByRole("button", { name: "Copy" }).click();
const recoveryKey = await app.getClipboard();
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
await expect(
settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }),
).toBeVisible();
await settingsDialogLocator.getByRole("textbox").fill(recoveryKey);
await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click();
await expectDehydratedDeviceEnabled(app);
// After recovery is set up, we reset our cryptographic identity, which
// should drop the dehydrated device.
await settingsDialogLocator.getByRole("button", { name: "Reset cryptographic identity" }).click();
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
await expectDehydratedDeviceDisabled(app);
});
});
async function getDehydratedDeviceIds(client: Client): Promise<string[]> {
@@ -172,16 +144,3 @@ async function expectDehydratedDeviceEnabled(app: ElementAppPage): Promise<void>
})
.toEqual(1);
}
/** Wait for our user to not have a dehydrated device */
async function expectDehydratedDeviceDisabled(app: ElementAppPage): Promise<void> {
// It might be nice to do this via the UI, but currently this info is not exposed via the UI.
//
// Note we might have to wait for the device list to be refreshed, so we wrap in `expect.poll`.
await expect
.poll(async () => {
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
return dehydratedDeviceIds.length;
})
.toEqual(0);
}

View File

@@ -21,7 +21,6 @@ import {
waitForVerificationRequest,
} from "./utils";
import { type Bot } from "../../pages/bot";
import { Toasts } from "../../pages/toasts.ts";
test.describe("Device verification", { tag: "@no-webkit" }, () => {
let aliceBotClient: Bot;
@@ -73,51 +72,6 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
});
// Regression test for https://github.com/element-hq/element-web/issues/29110
test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => {
// Before we log in, the bot creates an encrypted room, so that we can test the toast behaviour that only happens
// when we are in an encrypted room.
await aliceBotClient.createRoom({
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
},
],
});
// In order to simulate a real environment more accurately, we need to slow down the arrival of the
// `m.secret.send` to-device messages. That's slightly tricky to do directly, so instead we delay the *outgoing*
// `m.secret.request` messages.
await page.route("**/_matrix/client/v3/sendToDevice/m.secret.request/**", async (route) => {
await route.fulfill({ json: {} });
await new Promise((f) => setTimeout(f, 1000));
await route.fetch();
});
await logIntoElement(page, credentials);
// Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page);
// Handle emoji SAS verification
const infoDialog = page.locator(".mx_InfoDialog");
// the bot chooses to do an emoji verification
const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
// Handle emoji request and check that emojis are matching
await doTwoWaySasVerification(page, verifier);
await infoDialog.getByRole("button", { name: "They match" }).click();
await infoDialog.getByRole("button", { name: "Got it" }).click();
// There should be no toast (other than the notifications one)
const toasts = new Toasts(page);
await toasts.rejectToast("Notifications");
await toasts.assertNoToasts();
});
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
// A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key"
await logIntoElement(page, credentials);

View File

@@ -324,7 +324,7 @@ test.describe("Cryptography", function () {
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastE2eIcon.focus();
await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText(
"Sender's verified identity was reset",
"Sender's verified identity has changed",
);
});
});

View File

@@ -52,6 +52,6 @@ test.describe("Invisible cryptography", () => {
/* should show an error for a message from a previously verified device */
await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified");
const lastTile = page.locator(".mx_EventTile_last");
await expect(lastTile).toContainText("Sender's verified identity was reset");
await expect(lastTile).toContainText("Sender's verified identity has changed");
});
});

View File

@@ -29,9 +29,7 @@ test.describe("Key storage out of sync toast", () => {
});
test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => {
// We need to wait for there to be two toasts as the wait below won't work in isolation:
// playwright only evaluates the 'first()' call initially, not subsequent times it checks, so
// it would always be checking the same toast, even if another one is now the first.
// Need to wait for 2 to appear since playwright only evaluates 'first()' initially, so the waiting won't work
await expect(page.getByRole("alert")).toHaveCount(2);
await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png");

View File

@@ -221,9 +221,6 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key" }).click();
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" });
// If the user has set a recovery *passphrase*, they'll be prompted for that first and have to click
// through to enter the recovery key which is what we have here. If they haven't, they'll be prompted
// for a recovery key straight away. We click the button if it's there so this works in both cases.
if (await useSecurityKey.isVisible()) {
await useSecurityKey.click();
}
@@ -292,28 +289,17 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Ver
}
/**
* Open the encryption settings and enable key storage and recovery
* Assumes that the current device has been verified
* Open the security settings and enable secure key backup.
*
* Assumes that the current device has been cross-signed (which means that we skip a step where we set it up).
*
* Returns the recovery key
*/
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
const encryptionTab = await app.settings.openUserSettings("Encryption");
await app.settings.openUserSettings("Security & Privacy");
await app.page.getByRole("button", { name: "Set up Secure Backup" }).click();
const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" });
if (!(await keyStorageToggle.isChecked())) {
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click();
}
await encryptionTab.getByRole("button", { name: "Set up recovery" }).click();
await encryptionTab.getByRole("button", { name: "Continue" }).click();
const recoveryKey = await encryptionTab.getByTestId("recoveryKey").innerText();
await encryptionTab.getByRole("button", { name: "Continue" }).click();
await encryptionTab.getByRole("textbox").fill(recoveryKey);
await encryptionTab.getByRole("button", { name: "Finish set up" }).click();
await app.settings.closeDialog();
return recoveryKey;
return await completeCreateSecretStorageDialog(app.page);
}
/**

View File

@@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { type APIRequestContext } from "@playwright/test";
import { type APIRequestContext } from "playwright-core";
import { type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { ClientServerApi } from "@element-hq/element-web-playwright-common/lib/utils/api.js";
import { type HomeserverInstance } from "../plugins/homeserver";
import { ClientServerApi } from "../plugins/utils/api.ts";
/**
* A small subset of the Client-Server API used to manipulate the state of the

View File

@@ -267,6 +267,7 @@ test.describe("Editing", () => {
app,
room,
axe,
checkA11y,
}) => {
axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here
@@ -281,7 +282,7 @@ test.describe("Editing", () => {
const line = tile.locator(".mx_EventTile_line");
await line.hover();
await line.getByRole("button", { name: "Edit" }).click();
await expect(axe).toHaveNoViolations();
await checkA11y();
const editComposer = page.getByRole("textbox", { name: "Edit message" });
await editComposer.pressSequentially("Foo");
await editComposer.press("Backspace");
@@ -289,7 +290,7 @@ test.describe("Editing", () => {
await editComposer.press("Backspace");
await editComposer.press("Enter");
await app.getComposerField().hover(); // XXX: move the hover to get rid of the "Edit" tooltip
await expect(axe).toHaveNoViolations();
await checkA11y();
}
await expect(
page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: "Message" }),
@@ -304,6 +305,7 @@ test.describe("Editing", () => {
user,
app,
axe,
checkA11y,
bot: bob,
}) => {
// This tests the behaviour when a message has been edited some time after it has been sent, and we

View File

@@ -77,7 +77,7 @@ test.describe("Invite dialog", function () {
"should support inviting a user to Direct Messages",
{ tag: "@screenshot" },
async ({ page, app, user, bot }) => {
await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click();
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
const other = page.locator(".mx_InviteDialog_other");
// Assert that the header is rendered

View File

@@ -1,212 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../../element-web-test";
import type { Page } from "@playwright/test";
test.describe("Room list filters and sort", () => {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "BotBob",
autoAcceptInvites: true,
},
labsFlags: ["feature_new_room_list"],
});
function getPrimaryFilters(page: Page) {
return page.getByRole("listbox", { name: "Room list filters" });
}
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
test.beforeEach(async ({ page, app, bot, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
});
test.describe("Scroll behaviour", () => {
test("should scroll to the top of list when filter is applied and active room is not in filtered list", async ({
page,
app,
}) => {
const createFavouriteRoom = async (name: string) => {
const id = await app.client.createRoom({
name,
});
await app.client.evaluate(async (client, favouriteId) => {
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
}, id);
};
// Create 5 favourite rooms
let i = 0;
for (; i < 5; i++) {
await createFavouriteRoom(`room${i}-fav`);
}
// Create a non-favourite room
await app.client.createRoom({ name: `room-non-fav` });
// Create rest of the favourite rooms
for (; i < 20; i++) {
await createFavouriteRoom(`room${i}-fav`);
}
// Open the non-favourite room
const roomListView = getRoomList(page);
const tile = roomListView.getByRole("gridcell", { name: "Open room room-non-fav" });
await tile.scrollIntoViewIfNeeded();
await tile.click();
// Enable Favourite filter
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(tile).not.toBeVisible();
// Ensure the room list is not scrolled
const isScrolledDown = await page
.getByRole("grid", { name: "Room list" })
.evaluate((e) => e.scrollTop !== 0);
expect(isScrolledDown).toStrictEqual(false);
});
});
test.describe("Room list", () => {
let unReadDmId: string | undefined;
let unReadRoomId: string | undefined;
test.beforeEach(async ({ page, app, bot, user }) => {
await app.client.createRoom({ name: "empty room" });
unReadDmId = await bot.createRoom({
name: "unread dm",
invite: [user.userId],
is_direct: true,
});
await app.client.joinRoom(unReadDmId);
await bot.sendMessage(unReadDmId, "I am a robot. Beep.");
unReadRoomId = await app.client.createRoom({ name: "unread room" });
await app.client.inviteUser(unReadRoomId, bot.credentials.userId);
await bot.joinRoom(unReadRoomId);
await bot.sendMessage(unReadRoomId, "I am a robot. Beep.");
const favouriteId = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, favouriteId) => {
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
}, favouriteId);
});
test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomList = getRoomList(page);
const primaryFilters = getPrimaryFilters(page);
const allFilters = await primaryFilters.locator("option").all();
for (const filter of allFilters) {
expect(await filter.getAttribute("aria-selected")).toBe("false");
}
await expect(primaryFilters).toMatchScreenshot("unselected-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Unread" }).click();
// only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(3);
});
test("unread filter should only match unread rooms that have a count", async ({ page, app, bot }) => {
const roomListView = getRoomList(page);
// Let's configure unread dm room so that we only get notification for mentions and keywords
await app.viewRoomById(unReadDmId);
await app.settings.openRoomSettings("Notifications");
await page.getByText("@mentions & keywords").click();
await app.settings.closeDialog();
// Let's open a room other than unread room or unread dm
await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click();
// Let's make the bot send a new message in both rooms
await bot.sendMessage(unReadDmId, "Hello!");
await bot.sendMessage(unReadRoomId, "Hello!");
// Let's activate the unread filter now
await page.getByRole("option", { name: "Unread" }).click();
// Unread filter should only show unread room and not unread dm!
await expect(roomListView.getByRole("gridcell", { name: "Open room unread room" })).toBeVisible();
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
});
});
test.describe("Empty room list", () => {
/**
* Get the empty state
* @param page
*/
function getEmptyRoomList(page: Page) {
return page.getByTestId("empty-room-list");
}
test(
"should render the default placeholder when there is no filter",
{ tag: "@screenshot" },
async ({ page, app, user }) => {
const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot("default-empty-room-list.png");
await expect(page.getByTestId("room-list-panel")).toMatchScreenshot("room-panel-empty-room-list.png");
},
);
test("should render the placeholder for unread filter", { tag: "@screenshot" }, async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: "Unread" }).click();
const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot("unread-empty-room-list.png");
await emptyRoomList.getByRole("button", { name: "show all chats" }).click();
await expect(primaryFilters.getByRole("option", { name: "Unread" })).not.toBeChecked();
});
["People", "Rooms", "Favourite"].forEach((filter) => {
test(
`should render the placeholder for ${filter} filter`,
{ tag: "@screenshot" },
async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: filter }).click();
const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot(`${filter}-empty-room-list.png`);
},
);
});
});
});

View File

@@ -1,87 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../../element-web-test";
import type { Page } from "@playwright/test";
test.describe("Header section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the header section of the room list
* @param page
*/
function getHeaderSection(page: Page) {
return page.getByTestId("room-list-header");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
});
test("should render the header section", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListHeader = getHeaderSection(page);
await expect(roomListHeader).toMatchScreenshot("room-list-header.png");
const composeMenu = roomListHeader.getByRole("button", { name: "Add" });
await composeMenu.click();
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png");
// New message should open the direct messages dialog
await page.getByRole("menuitem", { name: "New message" }).click();
await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible();
await app.closeDialog();
// New room should open the room creation dialog
await composeMenu.click();
await page.getByRole("menuitem", { name: "New room" }).click();
await expect(page.getByRole("heading", { name: "Create a private room" })).toBeVisible();
await app.closeDialog();
});
test("should render the header section for a space", { tag: "@screenshot" }, async ({ page, app, user }) => {
await app.client.createSpace({ name: "MySpace" });
await page.getByRole("button", { name: "MySpace" }).click();
const roomListHeader = getHeaderSection(page);
await expect(roomListHeader).toMatchScreenshot("room-list-space-header.png");
await expect(roomListHeader.getByRole("heading", { name: "MySpace" })).toBeVisible();
await expect(roomListHeader.getByRole("button", { name: "Add" })).toBeVisible();
const spaceMenu = roomListHeader.getByRole("button", { name: "Open space menu" });
await spaceMenu.click();
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-space-menu.png");
// It should open the space home
await page.getByRole("menuitem", { name: "Space home" }).click();
await expect(page.getByRole("main").getByRole("heading", { name: "MySpace" })).toBeVisible();
// It should open the invite dialog
await spaceMenu.click();
await page.getByRole("menuitem", { name: "Invite" }).click();
await expect(page.getByRole("heading", { name: "Invite to MySpace" })).toBeVisible();
await app.closeDialog();
// It should open the space preferences
await spaceMenu.click();
await page.getByRole("menuitem", { name: "Preferences" }).click();
await expect(page.getByRole("heading", { name: "Preferences" })).toBeVisible();
await app.closeDialog();
// It should open the space settings
await spaceMenu.click();
await page.getByRole("menuitem", { name: "Space Settings" }).click();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
await app.closeDialog();
});
});

View File

@@ -1,264 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Room list", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_new_room_list"],
botCreateOpts: {
displayName: "BotBob",
},
});
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
});
test.describe("Room list", () => {
test.beforeEach(async ({ page, app, user }) => {
for (let i = 0; i < 30; i++) {
await app.client.createRoom({ name: `room${i}` });
}
});
test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list.png");
await roomListView.hover();
// Scroll to the end of the room list
await page.mouse.wheel(0, 1000);
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
});
test("should open the room when it is clicked", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
});
test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
await roomItem.hover();
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
const roomItemMenu = roomItem.getByRole("button", { name: "More Options" });
await roomItemMenu.click();
await expect(page).toMatchScreenshot("room-list-item-open-more-options.png");
// It should make the room favourited
await page.getByRole("menuitemcheckbox", { name: "Favourited" }).click();
// Check that the room is favourited
await roomItem.hover();
await roomItemMenu.click();
await expect(page.getByRole("menuitemcheckbox", { name: "Favourited" })).toBeChecked();
// It should show the invite dialog
await page.getByRole("menuitem", { name: "invite" }).click();
await expect(page.getByRole("heading", { name: "Invite to room29" })).toBeVisible();
await app.closeDialog();
// It should leave the room
await roomItem.hover();
await roomItemMenu.click();
await page.getByRole("menuitem", { name: "leave room" }).click();
await expect(roomItem).not.toBeVisible();
});
test("should open the notification options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
await roomItem.hover();
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
let roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
await roomItemMenu.click();
// Default settings should be selected
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page).toMatchScreenshot("room-list-item-open-notification-options.png");
// It should make the room muted
await page.getByRole("menuitem", { name: "Mute room" }).click();
// Remove hover on the room list item
await roomListView.hover();
// The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
await roomItem.hover();
// On hover, the room should show the muted icon
await expect(roomItem).toMatchScreenshot("room-list-item-hover-silent.png");
roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
await roomItemMenu.click();
// The Mute room option should be selected
await expect(page.getByRole("menuitem", { name: "Mute room" })).toHaveAttribute("aria-selected", "true");
await expect(page).toMatchScreenshot("room-list-item-open-notification-options-selection.png");
});
test("should scroll to the current room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.hover();
// Scroll to the end of the room list
await page.mouse.wheel(0, 1000);
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click();
const filters = page.getByRole("listbox", { name: "Room list filters" });
await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).not.toBeVisible();
await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
});
});
test.describe("Notification decoration", () => {
test("should render the invitation decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
await bot.createRoom({
name: "invited room",
invite: [user.userId],
is_direct: true,
});
const invitedRoom = roomListView.getByRole("gridcell", { name: "invited room" });
await expect(invitedRoom).toBeVisible();
await expect(invitedRoom).toMatchScreenshot("room-list-item-invited.png");
});
test("should render the regular decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "2 notifications" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "2 notifications" });
await expect(room).toBeVisible();
await expect(room.getByTestId("notification-decoration")).toHaveText("2");
await expect(room).toMatchScreenshot("room-list-item-notification.png");
});
test("should render the mention decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "mention" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
const clientBot = await bot.prepareClient();
await clientBot.evaluate(
async (client, { roomId, userId }) => {
await client.sendMessage(roomId, {
// @ts-ignore ignore usage of MsgType.text
"msgtype": "m.text",
"body": "User",
"format": "org.matrix.custom.html",
"formatted_body": `<a href="https://matrix.to/#/${userId}">User</a>`,
"m.mentions": {
user_ids: [userId],
},
});
},
{ roomId, userId: user.userId },
);
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "mention" });
await expect(room).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-mention.png");
});
test("should render an activity decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const otherRoomId = await app.client.createRoom({ name: "other room" });
const roomId = await app.client.createRoom({ name: "activity" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await app.viewRoomById(roomId);
await app.settings.openRoomSettings("Notifications");
await page.getByText("@mentions & keywords").click();
await app.settings.closeDialog();
await app.settings.openUserSettings("Notifications");
await page.getByText("Show all activity in the room list (dots or number of unread messages)").click();
await app.settings.closeDialog();
// Switch to the other room to avoid the notification to be cleared
await app.viewRoomById(otherRoomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "activity" });
await expect(room.getByTestId("notification-decoration")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-activity.png");
});
test("should render a mark as unread decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "mark as unread" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
const room = roomListView.getByRole("gridcell", { name: "mark as unread" });
await room.hover();
await room.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "mark as unread" }).click();
// Remove hover on the room list item
await roomListView.hover();
await expect(room).toMatchScreenshot("room-list-item-mark-as-unread.png");
});
test("should render silent decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "silent" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await app.viewRoomById(roomId);
await app.settings.openRoomSettings("Notifications");
await page.getByText("Off").click();
await app.settings.closeDialog();
const room = roomListView.getByRole("gridcell", { name: "silent" });
await expect(room.getByTestId("notification-decoration")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-silent.png");
});
});
});

View File

@@ -9,7 +9,7 @@ import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Room list panel", () => {
test.describe("Search section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
@@ -19,23 +19,16 @@ test.describe("Room list panel", () => {
* @param page
*/
function getRoomListView(page: Page) {
return page.getByTestId("room-list-panel");
return page.getByTestId("room-list-view");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
// Populate the room list
for (let i = 0; i < 20; i++) {
await app.client.createRoom({ name: `room${i}` });
}
});
test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => {
test("should render the room list view", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomListView(page);
// Wait for the last room to be visible
await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list-panel.png");
await expect(roomListView).toMatchScreenshot("room-list-view.png");
});
});

View File

@@ -6,14 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { type Page } from "playwright-core";
import { expect, test } from "../../element-web-test";
import { selectHomeserver } from "../utils";
import { type Credentials, type HomeserverInstance } from "../../plugins/homeserver";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { createBot } from "../crypto/utils.ts";
// This test requires fixed credentials for the device signing keys below to work
const username = "user1234";
@@ -121,7 +120,7 @@ test.describe("Login", () => {
credentials,
page,
homeserver,
axe,
checkA11y,
}) => {
await page.goto("/");
@@ -150,7 +149,7 @@ test.describe("Login", () => {
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
// Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688
// cy.percySnapshot("Login");
await expect(axe).toHaveNoViolations();
await checkA11y();
await page.getByRole("textbox", { name: "Username" }).fill(credentials.username);
await page.getByPlaceholder("Password").fill(credentials.password);
@@ -259,34 +258,6 @@ test.describe("Login", () => {
await expect(h1.locator(".mx_CompleteSecurity_skip")).toHaveCount(0);
});
test("Continues to show verification prompt after cancelling device verification", async ({
page,
homeserver,
credentials,
}) => {
// Create a different device which is cross-signed, meaning we need to verify this device
await createBot(page, homeserver, credentials, true);
// Wait to avoid homeserver rate limit on logins
await page.waitForTimeout(100);
// Load the page and see that we are asked to verify
await page.goto("/#/welcome");
await login(page, homeserver, credentials);
let h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
// Click "Verify with another device"
await page.getByRole("button", { name: "Verify with another device" }).click();
// Cancel the new dialog
await page.getByRole("button", { name: "Close dialog" }).click();
// Check that we are still being asked to verify
h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
});
});
});
});

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { type Locator, type Page } from "@playwright/test";
import { type Locator, type Page } from "playwright-core";
import { test, expect } from "../../element-web-test";

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type MailpitClient } from "@element-hq/element-web-playwright-common/lib/testcontainers";
import { type MailpitClient } from "mailpit-api";
import { type Page } from "@playwright/test";
import { expect } from "../../element-web-test";

View File

@@ -73,33 +73,4 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await revokeAccessTokenPromise;
await revokeRefreshTokenPromise;
});
test(
"it should log out the user & wipe data when logging out via MAS",
{ tag: "@screenshot" },
async ({ mas, page, mailpitClient }, testInfo) => {
// We use this over the `user` fixture to ensure we get an OIDC session rather than a compatibility one
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!");
await expect(page.getByText("Welcome")).toBeVisible();
await page.goto("about:blank");
// @ts-expect-error
const result = await mas.manage("kill-sessions", userId);
expect(result.output).toContain("Ended 1 active OAuth 2.0 session");
await page.goto("http://localhost:8080");
await expect(
page.getByText("For security, this session has been signed out. Please sign in again."),
).toBeVisible();
await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true });
const localStorageKeys = await page.evaluate(() => Object.keys(localStorage));
expect(localStorageKeys).toHaveLength(0);
},
);
});

View File

@@ -34,7 +34,7 @@ test.describe("Email Registration", async () => {
test(
"registers an account and lands on the home page",
{ tag: "@screenshot" },
async ({ page, mailpitClient, request, axe }) => {
async ({ page, mailpitClient, request, checkA11y }) => {
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
// Hide the server text as it contains the randomly allocated Homeserver port
const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] };
@@ -47,7 +47,7 @@ test.describe("Email Registration", async () => {
await expect(page.getByText("Check your email to continue")).toBeVisible();
await expect(page).toMatchScreenshot("registration_check_your_email.png", screenshotOptions);
await expect(axe).toHaveNoViolations();
await checkA11y();
await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible();

View File

@@ -33,12 +33,12 @@ test.describe("Registration", () => {
test(
"registers an account and lands on the home screen",
{ tag: "@screenshot" },
async ({ homeserver, page, axe, crypto }) => {
async ({ homeserver, page, checkA11y, crypto }) => {
await page.getByRole("button", { name: "Edit", exact: true }).click();
await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("server-picker.png");
await expect(axe).toHaveNoViolations();
await checkA11y();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.baseUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
@@ -52,7 +52,7 @@ test.describe("Registration", () => {
includeDialogBackground: true,
};
await expect(page).toMatchScreenshot("registration.png", screenshotOptions);
await expect(axe).toHaveNoViolations();
await checkA11y();
await page.getByRole("textbox", { name: "Username", exact: true }).fill("alice");
await page.getByPlaceholder("Password", { exact: true }).fill("totally a great password");
@@ -62,12 +62,12 @@ test.describe("Registration", () => {
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(page).toMatchScreenshot("email-prompt.png", screenshotOptions);
await expect(axe).toHaveNoViolations();
await checkA11y();
await dialog.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy")).toBeVisible();
await expect(page).toMatchScreenshot("terms-prompt.png", screenshotOptions);
await expect(axe).toHaveNoViolations();
await checkA11y();
const termsPolicy = page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy");
await termsPolicy.getByRole("checkbox").click(); // Click the checkbox before terms of service anchor link

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -10,7 +10,6 @@ import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const ROOM_NAME = "Test room";
const ROOM_NAME_LONG =
@@ -68,15 +67,6 @@ test.describe("RightPanel", () => {
},
);
test("should have padding under leave room", { tag: "@screenshot" }, async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
const leaveButton = await page.getByRole("menuitem", { name: "Leave Room" });
await leaveButton.scrollIntoViewIfNeeded();
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-leave-room.png");
});
test("should handle clicking add widgets", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
@@ -134,17 +124,6 @@ test.describe("RightPanel", () => {
await page.getByLabel("Room info").nth(1).click();
await checkRoomSummaryCard(page, ROOM_NAME);
});
test.describe("room reporting", () => {
test.skip(isDendrite, "Dendrite does not implement room reporting");
test("should handle reporting a room", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Report room" }).click();
const dialog = await page.getByRole("dialog", { name: "Report Room" });
await dialog.getByLabel("reason").fill("This room should be reported");
await dialog.getByRole("button", { name: "Send report" }).click();
await expect(page.getByText("Your report was sent.")).toBeVisible();
});
});
});
test.describe("in spaces", () => {

View File

@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { type Visibility } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
import { type ElementAppPage } from "../../pages/ElementAppPage";
@@ -86,15 +85,6 @@ test.describe("Room Header", () => {
await expect(header).toMatchScreenshot("room-header-long-name.png");
},
);
test("should render room header icon correctly", { tag: "@screenshot" }, async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test Room", visibility: "public" as Visibility });
await app.viewRoomByName("Test Room");
const header = page.locator(".mx_RoomHeader");
await expect(header).toMatchScreenshot("room-header-with-icon.png");
});
});
test.describe("with a video room", () => {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -50,8 +50,8 @@ test.describe("Appearance user settings tab", () => {
// Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click();
await tab.getByLabel("Use bundled emoji font").click();
await tab.getByLabel("Use a system font").click();
await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click();
await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click();
// Assert that the font-family value was removed
await expect(page.locator("body")).toHaveCSS("font-family", '""');

View File

@@ -17,7 +17,9 @@ import {
} from "../../crypto/utils";
test.describe("Encryption tab", () => {
test.use({ displayName: "Alice" });
test.use({
displayName: "Alice",
});
let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;
@@ -109,36 +111,4 @@ test.describe("Encryption tab", () => {
// The user is prompted to reset their identity
await expect(dialog.getByText("Forgot your recovery key? Youll need to reset your identity.")).toBeVisible();
});
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
await verifySession(app, recoveryKey.encodedPrivateKey);
await util.openEncryptionTab();
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
await expect(
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
const deleteRequestPromises = [
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")),
page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")),
];
await page.getByRole("button", { name: "Delete key storage" }).click();
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
for (const prom of deleteRequestPromises) {
const request = await prom;
expect(request.method()).toBe("PUT");
expect(request.postData()).toBe(JSON.stringify({}));
}
});
});

View File

@@ -28,10 +28,7 @@ test.describe("Preferences user settings tab", () => {
const tab = await app.settings.openUserSettings("Preferences");
// Assert that the top heading is rendered
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png", {
// masked due to daylight saving time
mask: [tab.locator("#mx_dropdownUserTimezone_value")],
});
await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png");
});
test("should be able to change the app language", { tag: ["@no-firefox", "@no-webkit"] }, async ({ uut, user }) => {

View File

@@ -1,18 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Quick settings menu", () => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
await page.getByRole("button", { name: "Quick settings" }).click();
// Assert that the top heading is renderedc
const settings = page.getByTestId("quick-settings-menu");
await expect(settings).toBeVisible();
await expect(settings).toMatchScreenshot("quick-settings.png");
});
});

View File

@@ -32,7 +32,7 @@ test.describe("Security user settings tab", () => {
});
test.describe("AnalyticsLearnMoreDialog", () => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page }) => {
const tab = await app.settings.openUserSettings("Security");
await tab.getByRole("button", { name: "Learn more" }).click();
await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot(
@@ -41,57 +41,16 @@ test.describe("Security user settings tab", () => {
});
});
test("should be able to set an ID server", async ({ app, context, user, page }) => {
test("should contain section to set ID server", async ({ app }) => {
const tab = await app.settings.openUserSettings("Security");
await context.route("https://identity.example.org/_matrix/identity/v2", async (route) => {
await route.fulfill({
status: 200,
json: {},
});
});
await context.route("https://identity.example.org/_matrix/identity/v2/account/register", async (route) => {
await route.fulfill({
status: 200,
json: {
token: "AToken",
},
});
});
await context.route("https://identity.example.org/_matrix/identity/v2/account", async (route) => {
await route.fulfill({
status: 200,
json: {
user_id: user.userId,
},
});
});
await context.route("https://identity.example.org/_matrix/identity/v2/terms", async (route) => {
await route.fulfill({
status: 200,
json: {
policies: {},
},
});
});
const setIdServer = tab.locator(".mx_IdentityServerPicker");
const setIdServer = tab.locator(".mx_SetIdServer");
await setIdServer.scrollIntoViewIfNeeded();
const textElement = setIdServer.getByRole("textbox", { name: "Enter a new identity server" });
await textElement.click();
await textElement.fill("https://identity.example.org");
await setIdServer.getByRole("button", { name: "Change" }).click();
await expect(setIdServer.getByText("Checking server")).toBeVisible();
// Accept terms
await page.getByTestId("dialog-primary-button").click();
// Check identity has changed.
await expect(setIdServer.getByText("Your identity server has been changed")).toBeVisible();
// Ensure section title is updated.
await expect(tab.getByText(`Identity server (identity.example.org)`, { exact: true })).toBeVisible();
// Assert that an input area for identity server exists
await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible();
});
test("should show integrations as enabled", async ({ app, page, user }) => {
test("should enable show integrations as enabled", async ({ app, page }) => {
const tab = await app.settings.openUserSettings("Security");
const setIntegrationManager = tab.locator(".mx_SetIntegrationManager");
@@ -102,9 +61,7 @@ test.describe("Security user settings tab", () => {
}),
).toBeVisible();
// Make sure integration manager's toggle switch is enabled
const toggleswitch = setIntegrationManager.getByLabel("Enable the integration manager");
await expect(toggleswitch).toBeVisible();
await expect(toggleswitch).toBeChecked();
await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible();
await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText(
"Manage integrations(scalar.vector.im)",
);

View File

@@ -7,15 +7,47 @@ Please see LICENSE files in the repository root for full details.
*/
import { type Page, type Request } from "@playwright/test";
import { GenericContainer, type StartedTestContainer, Wait } from "testcontainers";
import { test as base, expect } from "../../element-web-test";
import type { ElementAppPage } from "../../pages/ElementAppPage";
import type { Bot } from "../../pages/bot";
const test = base.extend<{
slidingSyncProxy: StartedTestContainer;
testRoom: { roomId: string; name: string };
joinedBot: Bot;
}>({
slidingSyncProxy: async ({ logger, network, postgres, page, homeserver }, use, testInfo) => {
const container = await new GenericContainer("ghcr.io/matrix-org/sliding-sync:v0.99.3")
.withNetwork(network)
.withExposedPorts(8008)
.withLogConsumer(logger.getConsumer("sliding-sync-proxy"))
.withWaitStrategy(Wait.forHttp("/client/server.json", 8008))
.withEnvironment({
SYNCV3_SECRET: "bwahahaha",
SYNCV3_DB: `user=${postgres.getUsername()} dbname=postgres password=${postgres.getPassword()} host=postgres sslmode=disable`,
SYNCV3_SERVER: `http://homeserver:8008`,
})
.start();
const proxyAddress = `http://${container.getHost()}:${container.getMappedPort(8008)}`;
await page.addInitScript((proxyAddress) => {
window.localStorage.setItem(
"mx_local_settings",
JSON.stringify({
feature_sliding_sync_proxy_url: proxyAddress,
}),
);
window.localStorage.setItem("mx_labs_feature_feature_sliding_sync", "true");
}, proxyAddress);
await use(container);
await container.stop();
},
// Ensure slidingSyncProxy is set up before the user fixture as it relies on an init script
credentials: async ({ slidingSyncProxy, credentials }, use) => {
await use(credentials);
},
testRoom: async ({ user, app }, use) => {
const name = "Test Room";
const roomId = await app.client.createRoom({ name });
@@ -50,14 +82,6 @@ test.describe("Sliding Sync", () => {
});
};
test.use({
config: {
features: {
feature_simplified_sliding_sync: true,
},
},
});
// Load the user fixture for all tests
test.beforeEach(({ user }) => {});
@@ -164,7 +188,15 @@ test.describe("Sliding Sync", () => {
).not.toBeAttached();
});
test("should show unread indicators", async ({ page, app, joinedBot: bot, testRoom }) => {
test("should not show unread indicators", async ({ page, app, joinedBot: bot, testRoom }) => {
// TODO: for now. Later we should.
// disable notifs in this room (TODO: CS API call?)
const locator = page.getByRole("treeitem", { name: "Test Room" });
await locator.hover();
await locator.getByRole("button", { name: "Notification options" }).click();
await page.getByRole("menuitemradio", { name: "Mute room" }).click();
// create a new room so we know when the message has been received as it'll re-shuffle the room list
await app.client.createRoom({ name: "Dummy" });
@@ -175,7 +207,9 @@ test.describe("Sliding Sync", () => {
// wait for this message to arrive, tell by the room list resorting
await checkOrder(["Test Room", "Dummy"], page);
await expect(page.getByRole("treeitem", { name: "Test Room" }).locator(".mx_NotificationBadge")).toBeAttached();
await expect(
page.getByRole("treeitem", { name: "Test Room" }).locator(".mx_NotificationBadge"),
).not.toBeAttached();
});
test("should update user settings promptly", async ({ page, app }) => {
@@ -187,37 +221,6 @@ test.describe("Sliding Sync", () => {
await expect(locator.locator(".mx_ToggleSwitch_on")).toBeAttached();
});
test("should send subscribe_rooms on room switch if room not already subscribed", async ({ page, app }) => {
// create rooms and check room names are correct
const roomIds: string[] = [];
for (const fruit of ["Apple", "Pineapple", "Orange"]) {
const id = await app.client.createRoom({ name: fruit });
roomIds.push(id);
await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible();
}
const [roomAId, roomPId] = roomIds;
const matchRoomSubRequest = (subRoomId: string) => (request: Request) => {
if (!request.url().includes("/sync")) return false;
const body = request.postDataJSON();
return body.room_subscriptions?.[subRoomId];
};
// Select the Test Room and wait for playwright to get the request
const [request] = await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomAId)),
page.getByRole("treeitem", { name: "Apple", exact: true }).click(),
]);
const roomSubscriptions = request.postDataJSON().room_subscriptions;
expect(roomSubscriptions, "room_subscriptions is object").toBeDefined();
// Switch to another room and wait for playwright to get the request
await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomPId)),
page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(),
]);
});
test("should show and be able to accept/reject/rescind invites", async ({
page,
app,
@@ -358,4 +361,52 @@ test.describe("Sliding Sync", () => {
// ensure the reply-to does not disappear
await expect(page.locator(".mx_ReplyPreview")).toBeVisible();
});
test("should send unsubscribe_rooms for every room switch", async ({ page, app }) => {
// create rooms and check room names are correct
const roomIds: string[] = [];
for (const fruit of ["Apple", "Pineapple", "Orange"]) {
const id = await app.client.createRoom({ name: fruit });
roomIds.push(id);
await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible();
}
const [roomAId, roomPId, roomOId] = roomIds;
const matchRoomSubRequest = (subRoomId: string) => (request: Request) => {
if (!request.url().includes("/sync")) return false;
const body = request.postDataJSON();
return body.txn_id && body.room_subscriptions?.[subRoomId];
};
const matchRoomUnsubRequest = (unsubRoomId: string) => (request: Request) => {
if (!request.url().includes("/sync")) return false;
const body = request.postDataJSON();
return (
body.txn_id && body.unsubscribe_rooms?.includes(unsubRoomId) && !body.room_subscriptions?.[unsubRoomId]
);
};
// Select the Test Room and wait for playwright to get the request
const [request] = await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomAId)),
page.getByRole("treeitem", { name: "Apple", exact: true }).click(),
]);
const roomSubscriptions = request.postDataJSON().room_subscriptions;
expect(roomSubscriptions, "room_subscriptions is object").toBeDefined();
// Switch to another room and wait for playwright to get the request
await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomPId)),
page.waitForRequest(matchRoomUnsubRequest(roomAId)),
page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(),
]);
// And switch to even another room and wait for playwright to get the request
await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomOId)),
page.waitForRequest(matchRoomUnsubRequest(roomPId)),
page.getByRole("treeitem", { name: "Orange", exact: true }).click(),
]);
// TODO: Add tests for encrypted rooms
});
});

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -35,18 +35,17 @@ function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateR
name: spaceName,
},
},
...roomIds.map((r) => spaceChildInitialState(r)),
...roomIds.map(spaceChildInitialState),
],
};
}
function spaceChildInitialState(roomId: string, order?: string): ICreateRoomOpts["initial_state"]["0"] {
function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state"]["0"] {
return {
type: "m.space.child",
state_key: roomId,
content: {
via: [roomId.split(":")[1]],
order,
},
};
}
@@ -122,10 +121,9 @@ test.describe("Spaces", () => {
await page.getByRole("button", { name: "Skip for now" }).click();
// Assert rooms exist in the room list
const roomList = page.getByRole("tree", { name: "Rooms" });
await expect(roomList.getByRole("treeitem", { name: "General", exact: true })).toBeVisible();
await expect(roomList.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible();
await expect(roomList.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "General", exact: true })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible();
// Assert rooms exist in the space explorer
await expect(
@@ -157,7 +155,7 @@ test.describe("Spaces", () => {
await page.getByRole("button", { name: "Just me" }).click();
await page.getByRole("checkbox", { name: "Sample Room" }).click();
await page.getByText("Sample Room").click({ force: true }); // force click as checkbox size is zero
// Temporal implementation as multiple elements with the role "button" and name "Add" are found
await page.locator(".mx_AddExistingToSpace_footer").getByRole("button", { name: "Add" }).click();
@@ -167,50 +165,6 @@ test.describe("Spaces", () => {
).toBeVisible();
});
test(
"should allow user to add an existing room to a space after creation",
{ tag: "@screenshot" },
async ({ page, app, user }) => {
await app.client.createRoom({
name: "Sample Room",
});
await app.client.createRoom({
name: "A Room that will not be selected",
});
const menu = await openSpaceCreateMenu(page);
await menu.getByRole("button", { name: "Private" }).click();
await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu
.getByRole("textbox", { name: "Description" })
.fill("This is a personal space to mourn Riot.im...");
await menu.getByRole("textbox", { name: "Name" }).fill("This is my Riot");
await menu.getByRole("textbox", { name: "Name" }).press("Enter");
await page.getByRole("button", { name: "Just me" }).click();
await page.getByRole("button", { name: "Skip for now" }).click();
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "Add existing room" }).click();
await page.getByRole("checkbox", { name: "Sample Room" }).click();
await expect(page.getByRole("dialog", { name: "Avatar Add existing rooms" })).toMatchScreenshot(
"add-existing-rooms-dialog.png",
);
await page.getByRole("button", { name: "Add" }).click();
await expect(
page.locator(".mx_SpaceHierarchy_list").getByRole("treeitem", { name: "Sample Room" }),
).toBeVisible();
},
);
test("should allow user to invite another to a space", { tag: "@no-webkit" }, async ({ page, app, user, bot }) => {
await app.client.createSpace({
visibility: "public" as any,
@@ -273,7 +227,7 @@ test.describe("Spaces", () => {
test(
"should render subspaces in the space panel only when expanded",
{ tag: "@screenshot" },
async ({ page, app, user, axe }) => {
async ({ page, app, user, axe, checkA11y }) => {
axe.disableRules([
// Disable this check as it triggers on nested roving tab index elements which are in practice fine
"nested-interactive",
@@ -295,7 +249,7 @@ test.describe("Spaces", () => {
await expect(spaceTree.getByRole("button", { name: "Root Space" })).toBeVisible();
await expect(spaceTree.getByRole("button", { name: "Child Space" })).not.toBeVisible();
await expect(axe).toHaveNoViolations();
await checkA11y();
await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-collapsed.png");
// This finds the expand button with the class name "mx_SpaceButton_toggleCollapse". Note there is another
@@ -307,7 +261,7 @@ test.describe("Spaces", () => {
await expect(item).toBeVisible();
await expect(item.locator(".mx_SpaceItem", { hasText: "Child Space" })).toBeVisible();
await expect(axe).toHaveNoViolations();
await checkA11y();
await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-expanded.png");
},
);
@@ -337,36 +291,4 @@ test.describe("Spaces", () => {
// Assert we get shown the new room intro, and thus not the soft crash screen
await expect(page.locator(".mx_NewRoomIntro")).toBeVisible();
});
test("should render spaces view", { tag: "@screenshot" }, async ({ page, app, user, axe }) => {
axe.disableRules([
// Disable this check as it triggers on nested roving tab index elements which are in practice fine
"nested-interactive",
// XXX: We have some known contrast issues here
"color-contrast",
]);
const childSpaceId1 = await app.client.createSpace({
name: "Child Space 1",
initial_state: [],
});
const childSpaceId2 = await app.client.createSpace({
name: "Child Space 2",
initial_state: [],
});
const childSpaceId3 = await app.client.createSpace({
name: "Child Space 3",
initial_state: [],
});
await app.client.createSpace({
name: "Root Space",
initial_state: [
spaceChildInitialState(childSpaceId1, "a"),
spaceChildInitialState(childSpaceId2, "b"),
spaceChildInitialState(childSpaceId3, "c"),
],
});
await app.viewSpaceByName("Root Space");
await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("space-room-view.png");
});
});

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -28,8 +28,6 @@ const NEW_AVATAR = fs.readFileSync("playwright/sample-files/element.png");
const OLD_NAME = "Alan";
const NEW_NAME = "Alan (away)";
const VIDEO_FILE = fs.readFileSync("playwright/sample-files/5secvid.webm");
const getEventTilesWithBodies = (page: Page): Locator => {
return page.locator(".mx_EventTile").filter({ has: page.locator(".mx_EventTile_body") });
};
@@ -279,7 +277,7 @@ test.describe("Timeline", () => {
test(
"should add inline start margin to an event line on IRC layout",
{ tag: "@screenshot" },
async ({ page, app, room, axe }) => {
async ({ page, app, room, axe, checkA11y }) => {
axe.disableRules("color-contrast");
await page.goto(`/#/room/${room.roomId}`);
@@ -320,7 +318,7 @@ test.describe("Timeline", () => {
`,
},
);
await expect(axe).toHaveNoViolations();
await checkA11y();
},
);
});
@@ -745,64 +743,68 @@ test.describe("Timeline", () => {
).toBeVisible();
});
test("should render url previews", { tag: "@screenshot" }, async ({ page, app, room, axe, context }) => {
axe.disableRules("color-contrast");
test(
"should render url previews",
{ tag: "@screenshot" },
async ({ page, app, room, axe, checkA11y, context }) => {
axe.disableRules("color-contrast");
// Element Web uses a Service Worker to rewrite unauthenticated media requests to authenticated ones, but
// the page can't see this happening. We intercept the route at the BrowserContext to ensure we get it
// post-worker, but we can't waitForResponse on that, so the page context is still used there. Because
// the page doesn't see the rewrite, it waits for the unauthenticated route. This is only confusing until
// the js-sdk (and thus the app as a whole) switches to using authenticated endpoints by default, hopefully.
await context.route(
"**/_matrix/client/v1/media/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*",
async (route) => {
await route.fulfill({
path: "playwright/sample-files/riot.png",
});
},
);
await page.route(
"**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*",
async (route) => {
await route.fulfill({
json: {
"og:title": "Element Call",
"og:description": null,
"og:image:width": 48,
"og:image:height": 48,
"og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV",
"og:image:type": "image/png",
"matrix:image:size": 2121,
},
});
},
);
// Element Web uses a Service Worker to rewrite unauthenticated media requests to authenticated ones, but
// the page can't see this happening. We intercept the route at the BrowserContext to ensure we get it
// post-worker, but we can't waitForResponse on that, so the page context is still used there. Because
// the page doesn't see the rewrite, it waits for the unauthenticated route. This is only confusing until
// the js-sdk (and thus the app as a whole) switches to using authenticated endpoints by default, hopefully.
await context.route(
"**/_matrix/client/v1/media/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*",
async (route) => {
await route.fulfill({
path: "playwright/sample-files/riot.png",
});
},
);
await page.route(
"**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*",
async (route) => {
await route.fulfill({
json: {
"og:title": "Element Call",
"og:description": null,
"og:image:width": 48,
"og:image:height": 48,
"og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV",
"og:image:type": "image/png",
"matrix:image:size": 2121,
},
});
},
);
const requestPromises: Promise<any>[] = [
page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"),
// see context.route above for why we listen for the unauthenticated endpoint
page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"),
];
const requestPromises: Promise<any>[] = [
page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"),
// see context.route above for why we listen for the unauthenticated endpoint
page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"),
];
await app.client.sendMessage(room.roomId, "https://call.element.io/");
await page.goto(`/#/room/${room.roomId}`);
await app.client.sendMessage(room.roomId, "https://call.element.io/");
await page.goto(`/#/room/${room.roomId}`);
await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible();
await Promise.all(requestPromises);
await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible();
await Promise.all(requestPromises);
await expect(axe).toHaveNoViolations();
await checkA11y();
await app.timeline.scrollToBottom();
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", {
// Exclude timestamp and read marker from snapshot
mask: [page.locator(".mx_MessageTimestamp")],
css: `
await app.timeline.scrollToBottom();
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", {
// Exclude timestamp and read marker from snapshot
mask: [page.locator(".mx_MessageTimestamp")],
css: `
.mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
display: none !important;
}
`,
});
});
});
},
);
test.describe("on search results panel", () => {
test(
@@ -873,73 +875,6 @@ test.describe("Timeline", () => {
);
});
});
test("should render a code block", { tag: "@screenshot" }, async ({ page, app, room }) => {
await page.goto(`/#/room/${room.roomId}`);
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
// Wait until configuration is finished
await expect(
page
.locator(".mx_GenericEventListSummary_summary")
.getByText(`${OLD_NAME} created and configured the room.`),
).toBeVisible();
// Send a code block
const composer = app.getComposerField();
await composer.fill("```\nconsole.log('Hello, world!');\n```");
await composer.press("Enter");
const tile = page.locator(".mx_EventTile");
await expect(tile).toBeVisible();
await expect(tile).toMatchScreenshot("code-block.png", { mask: [page.locator(".mx_MessageTimestamp")] });
// Edit a code block and assert the edited code block has been correctly rendered
await tile.hover();
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }).click();
await page
.getByRole("textbox", { name: "Edit message" })
.fill("```\nconsole.log('Edited: Hello, world!');\n```");
await page.getByRole("textbox", { name: "Edit message" }).press("Enter");
const newTile = page.locator(".mx_EventTile");
await expect(newTile).toMatchScreenshot("edited-code-block.png", {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should be able to hide an image", { tag: "@screenshot" }, async ({ page, app, room, context }) => {
await app.viewRoomById(room.roomId);
await sendImage(app.client, room.roomId, NEW_AVATAR);
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await page.getByRole("button", { name: "Hide" }).click();
// Check that the image is now hidden.
await expect(page.getByRole("button", { name: "Show image" })).toBeVisible();
});
test("should be able to hide a video", async ({ page, app, room, context }) => {
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(VIDEO_FILE, { name: "bbb.webm", type: "video/webm" });
await app.client.sendEvent(room.roomId, null, "m.room.message" as EventType, {
msgtype: "m.video" as MsgType,
body: "bbb.webm",
url: upload.content_uri,
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MVideoBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await page.getByRole("button", { name: "Hide" }).click();
// Check that the video is now hidden.
await expect(page.getByRole("button", { name: "Show video" })).toBeVisible();
await expect(page.locator("video")).not.toBeVisible();
});
});
test.describe("message sending", { tag: ["@no-firefox", "@no-webkit"] }, () => {
@@ -1341,44 +1276,4 @@ test.describe("Timeline", () => {
);
});
});
test.describe("spoilers", { tag: "@screenshot" }, () => {
test("clicking a spoiler containing the pill de-spoilers on 1st click, then follows link on 2nd", async ({
page,
user,
app,
room,
}) => {
// View room
await page.goto(`/#/room/${room.roomId}`);
// Send a spoilered pill
await app.client.sendMessage(room.roomId, {
msgtype: "m.text",
body: user.userId,
format: "org.matrix.custom.html",
formatted_body: `<span data-mx-spoiler>https://matrix.to/#/${user.userId}</span>`,
});
const screenshotOptions = {
css: `
.mx_MessageTimestamp {
display: none !important;
}
`,
};
const eventTile = page.locator(".mx_RoomView_body .mx_EventTile_last");
await expect(eventTile).toMatchScreenshot("spoiler.png", screenshotOptions);
const rightPanelButton = page.getByText("Share profile");
const pill = page.locator(".mx_UserPill");
await pill.click({ force: true }); // force to click the spoiler wrapper instead
await expect(eventTile).toMatchScreenshot("spoiler-uncovered.png", screenshotOptions);
await expect(rightPanelButton).not.toBeVisible(); // assert the right panel is not yet open
await pill.click();
await expect(rightPanelButton).toBeVisible(); // assert the right panel is open
});
});
});

View File

@@ -7,18 +7,18 @@ Please see LICENSE files in the repository root for full details.
*/
import {
type ExpectMatcherState,
type MatcherReturnType,
type Page,
expect as baseExpect,
type Locator,
type Page,
type ExpectMatcherState,
type ElementHandle,
type PlaywrightTestArgs,
type Fixtures as _Fixtures,
} from "@playwright/test";
import {
type TestFixtures as BaseTestFixtures,
expect as baseExpect,
type ToMatchScreenshotOptions,
} from "@element-hq/element-web-playwright-common";
import { sanitizeForFilePath } from "playwright-core/lib/utils";
import AxeBuilder from "@axe-core/playwright";
import _ from "lodash";
import { extname } from "node:path";
import type { IConfigOptions } from "../src/IConfigOptions";
import { type Credentials } from "./plugins/homeserver";
@@ -27,22 +27,71 @@ import { Crypto } from "./pages/crypto";
import { Toasts } from "./pages/toasts";
import { Bot, type CreateBotOpts } from "./pages/bot";
import { Webserver } from "./plugins/webserver";
import { type WorkerOptions, type Services, test as base } from "./services";
import { type Options, type Services, test as base } from "./services.ts";
// Enable experimental service worker support
// See https://playwright.dev/docs/service-workers-experimental#how-to-enable
process.env["PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS"] = "1";
declare module "@element-hq/element-web-playwright-common" {
// Improve the type for the config fixture based on the real type
export interface Config extends Omit<IConfigOptions, "default_server_config"> {}
}
// This is deliberately quite a minimal config.json, so that we can test that the default settings actually work.
const CONFIG_JSON: Partial<IConfigOptions> = {
// The default language is set here for test consistency
setting_defaults: {
language: "en-GB",
},
// the location tests want a map style url.
map_style_url: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx",
features: {
// We don't want to go through the feature announcement during the e2e test
feature_release_announcement: false,
},
};
export interface CredentialsWithDisplayName extends Credentials {
displayName: string;
}
export interface TestFixtures extends BaseTestFixtures {
export interface TestFixtures {
axe: AxeBuilder;
checkA11y: () => Promise<void>;
/**
* The contents of the config.json to send when the client requests it.
*/
config: typeof CONFIG_JSON;
/**
* The displayname to use for the user registered in {@link #credentials}.
*
* To set it, call `test.use({ displayName: "myDisplayName" })` in the test file or `describe` block.
* See {@link https://playwright.dev/docs/api/class-test#test-use}.
*/
displayName?: string;
/**
* A test fixture which registers a test user on the {@link #homeserver} and supplies the details
* of the registered user.
*/
credentials: CredentialsWithDisplayName;
/**
* The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`},
* but adds an initScript which will populate localStorage with the user's details from
* {@link #credentials} and {@link #homeserver}.
*
* Similar to {@link #user}, but doesn't load the app.
*/
pageWithCredentials: Page;
/**
* A (rather poorly-named) test fixture which registers a user per {@link #credentials}, stores
* the credentials into localStorage per {@link #homeserver}, and then loads the front page of the
* app.
*/
user: CredentialsWithDisplayName;
/**
* The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`},
* but wraps the returned `Page` in a class of utilities for interacting with the Element-Web UI,
@@ -56,11 +105,13 @@ export interface TestFixtures extends BaseTestFixtures {
uut?: Locator; // Unit Under Test, useful place to refer a prepared locator
botCreateOpts: CreateBotOpts;
bot: Bot;
labsFlags: string[];
webserver: Webserver;
disablePresence: boolean;
}
type CombinedTestFixtures = PlaywrightTestArgs & TestFixtures;
export type Fixtures = _Fixtures<CombinedTestFixtures, Services & WorkerOptions, CombinedTestFixtures>;
export type Fixtures = _Fixtures<CombinedTestFixtures, Services & Options, CombinedTestFixtures>;
export const test = base.extend<TestFixtures>({
context: async ({ context }, use, testInfo) => {
// We skip tests instead of using grep-invert to still surface the counts in the html report
@@ -70,12 +121,102 @@ export const test = base.extend<TestFixtures>({
);
await use(context);
},
axe: async ({ axe }, use) => {
// Exclude floating UI for now
await use(axe.exclude("[data-floating-ui-portal]"));
disablePresence: false,
config: {}, // We merge this atop the default CONFIG_JSON in the page fixture to make extending it easier
page: async ({ homeserver, context, page, config, labsFlags, disablePresence }, use) => {
await context.route(`http://localhost:8080/config.json*`, async (route) => {
const json = {
...CONFIG_JSON,
...config,
default_server_config: {
"m.homeserver": {
base_url: homeserver.baseUrl,
},
...config.default_server_config,
},
};
json["features"] = {
...json["features"],
// Enable the lab features
...labsFlags.reduce((obj, flag) => {
obj[flag] = true;
return obj;
}, {}),
};
if (disablePresence) {
json["enable_presence_by_hs_url"] = {
[homeserver.baseUrl]: false,
};
}
await route.fulfill({ json });
});
await use(page);
},
displayName: undefined,
credentials: async ({ context, homeserver, displayName: testDisplayName }, use, testInfo) => {
const names = ["Alice", "Bob", "Charlie", "Daniel", "Eve", "Frank", "Grace", "Hannah", "Isaac", "Judy"];
const password = _.uniqueId("password_");
const displayName = testDisplayName ?? _.sample(names)!;
const credentials = await homeserver.registerUser(`user_${testInfo.testId}`, password, displayName);
console.log(`Registered test user ${credentials.userId} with displayname ${displayName}`);
await use({
...credentials,
displayName,
});
},
labsFlags: [],
pageWithCredentials: async ({ page, homeserver, credentials }, use) => {
await page.addInitScript(
({ baseUrl, credentials }) => {
// Seed the localStorage with the required credentials
window.localStorage.setItem("mx_hs_url", baseUrl);
window.localStorage.setItem("mx_user_id", credentials.userId);
window.localStorage.setItem("mx_access_token", credentials.accessToken);
window.localStorage.setItem("mx_device_id", credentials.deviceId);
window.localStorage.setItem("mx_is_guest", "false");
window.localStorage.setItem("mx_has_pickle_key", "false");
window.localStorage.setItem("mx_has_access_token", "true");
window.localStorage.setItem(
"mx_local_settings",
JSON.stringify({
// Retain any other settings which may have already been set
...JSON.parse(window.localStorage.getItem("mx_local_settings") || "{}"),
// Ensure the language is set to a consistent value
language: "en",
}),
);
},
{ baseUrl: homeserver.baseUrl, credentials },
);
await use(page);
},
user: async ({ pageWithCredentials: page, credentials }, use) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await use(credentials);
},
axe: async ({ page }, use) => {
await use(new AxeBuilder({ page }).exclude("[data-floating-ui-portal]"));
},
checkA11y: async ({ axe }, use, testInfo) =>
use(async () => {
const results = await axe.analyze();
await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(results, null, 2),
contentType: "application/json",
});
expect(results.violations).toEqual([]);
}),
app: async ({ page }, use) => {
const app = new ElementAppPage(page);
await use(app);
@@ -103,23 +244,35 @@ export const test = base.extend<TestFixtures>({
},
});
interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
includeDialogBackground?: boolean;
showTooltips?: boolean;
timeout?: number;
// Based on https://github.com/microsoft/playwright/blob/2b77ed4d7aafa85a600caa0b0d101b72c8437eeb/packages/playwright/src/util.ts#L206C8-L210C2
function sanitizeFilePathBeforeExtension(filePath: string): string {
const ext = extname(filePath);
const base = filePath.substring(0, filePath.length - ext.length);
return sanitizeForFilePath(base) + ext;
}
type Expectations = {
toMatchScreenshot: (
export const expect = baseExpect.extend({
async toMatchScreenshot(
this: ExpectMatcherState,
receiver: Page | Locator,
name: `${string}.png`,
options?: ExtendedToMatchScreenshotOptions,
) => Promise<MatcherReturnType>;
};
options?: {
mask?: Array<Locator>;
includeDialogBackground?: boolean;
showTooltips?: boolean;
timeout?: number;
css?: string;
},
) {
const testInfo = test.info();
if (!testInfo) throw new Error(`toMatchScreenshot() must be called during the test`);
if (!testInfo.tags.includes("@screenshot")) {
throw new Error("toMatchScreenshot() must be used in a test tagged with @screenshot");
}
const page = "page" in receiver ? receiver.page() : receiver;
export const expect = baseExpect.extend<Expectations>({
async toMatchScreenshot(receiver, name, options) {
let css = `
.mx_MessagePanel_myReadMarker {
display: none !important;
@@ -169,9 +322,21 @@ export const expect = baseExpect.extend<Expectations>({
css += options.css;
}
await baseExpect(receiver).toMatchScreenshot(name, {
...options,
css,
// We add a custom style tag before taking screenshots
const style = (await page.addStyleTag({
content: css,
})) as ElementHandle<Element>;
const screenshotName = sanitizeFilePathBeforeExtension(name);
await baseExpect(receiver).toHaveScreenshot(screenshotName, options);
await style.evaluate((tag) => tag.remove());
testInfo.annotations.push({
// `_` prefix hides it from the HTML reporter
type: "_screenshot",
// include a path relative to `playwright/snapshots/`
description: testInfo.snapshotPath(screenshotName).split("/playwright/snapshots/", 2)[1],
});
return { pass: true, message: () => "", name: "toMatchScreenshot" };

63
playwright/logger.ts Normal file
View File

@@ -0,0 +1,63 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type BrowserContext, type Page, type TestInfo } from "@playwright/test";
import { type Readable } from "stream";
import stripAnsi from "strip-ansi";
export class Logger {
private pages: Page[] = [];
private logs: Record<string, string> = {};
public getConsumer(container: string) {
this.logs[container] = "";
return (stream: Readable) => {
stream.on("data", (chunk) => {
this.logs[container] += chunk.toString();
});
stream.on("err", (chunk) => {
this.logs[container] += "ERR " + chunk.toString();
});
};
}
public async onTestStarted(context: BrowserContext) {
this.pages = [];
for (const id in this.logs) {
if (id.startsWith("page-")) {
delete this.logs[id];
} else {
this.logs[id] = "";
}
}
context.on("console", (msg) => {
const page = msg.page();
let pageIdx = this.pages.indexOf(page);
if (pageIdx === -1) {
this.pages.push(page);
pageIdx = this.pages.length - 1;
this.logs[`page-${pageIdx}`] = `Console logs for page with URL: ${page.url()}\n\n`;
}
const type = msg.type();
const text = msg.text();
this.logs[`page-${pageIdx}`] += `${type}: ${text}\n`;
});
}
public async onTestFinished(testInfo: TestInfo) {
if (testInfo.status !== "passed") {
for (const id in this.logs) {
if (!this.logs[id]) continue;
await testInfo.attach(id, {
body: stripAnsi(this.logs[id]),
contentType: "text/plain",
});
}
}
}
}

View File

@@ -114,7 +114,7 @@ export class ElementAppPage {
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
*/
public getComposerField(isRightPanel?: boolean): Locator {
return this.getComposer(isRightPanel).locator("div[contenteditable]");
return this.getComposer(isRightPanel).locator("[contenteditable]");
}
/**

View File

@@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type WorkerOptions } from "../../../services.ts";
import { type Options } from "../../../services.ts";
export const isDendrite = ({ homeserverType }: WorkerOptions): boolean => {
export const isDendrite = ({ homeserverType }: Options): boolean => {
return homeserverType === "dendrite" || homeserverType === "pinecone";
};

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type ClientServerApi } from "@element-hq/element-web-playwright-common/lib/utils/api.js";
import { type ClientServerApi } from "../utils/api.ts";
export interface HomeserverInstance {
readonly baseUrl: string;

View File

@@ -6,19 +6,30 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type SynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
import { type Fixtures } from "../../../element-web-test.ts";
export const consentHomeserver: Fixtures = {
_homeserver: [
async ({ _homeserver: container, mailpit }, use) => {
(container as SynapseContainer)
container
.withCopyDirectoriesToContainer([
{ source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" },
])
.withSmtpServer(mailpit)
.withConfig({
email: {
enable_notifs: false,
smtp_host: "mailpit",
smtp_port: 1025,
smtp_user: "username",
smtp_pass: "password",
require_transport_security: false,
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
app_name: "Matrix",
notif_template_html: "notif_mail.html",
notif_template_text: "notif_mail.txt",
notif_for_new_users: true,
client_base_url: "http://localhost/element",
},
user_consent: {
template_dir: "/data/res/templates/privacy",
version: "1.0",

View File

@@ -6,8 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { MatrixAuthenticationServiceContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
import { MatrixAuthenticationServiceContainer } from "../../../testcontainers/mas.ts";
import { type Fixtures } from "../../../element-web-test.ts";
export const masHomeserver: Fixtures = {

View File

@@ -10,7 +10,8 @@ import http from "http";
import express from "express";
import { type AddressInfo } from "net";
import { type TestInfo } from "@playwright/test";
import { randB64Bytes } from "@element-hq/element-web-playwright-common/lib/utils/rand.js";
import { randB64Bytes } from "../utils/rand.ts";
export class OAuthServer {
private server?: http.Server;

View File

@@ -0,0 +1,76 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type APIRequestContext } from "@playwright/test";
import { type Credentials } from "../homeserver";
export type Verb = "GET" | "POST" | "PUT" | "DELETE";
export class Api {
private _request?: APIRequestContext;
constructor(private readonly baseUrl: string) {}
public setRequest(request: APIRequestContext): void {
this._request = request;
}
public async request<R extends {}>(verb: "GET", path: string, token?: string, data?: never): Promise<R>;
public async request<R extends {}>(verb: Verb, path: string, token?: string, data?: object): Promise<R>;
public async request<R extends {}>(verb: Verb, path: string, token?: string, data?: object): Promise<R> {
const url = `${this.baseUrl}${path}`;
const res = await this._request.fetch(url, {
data,
method: verb,
headers: token
? {
Authorization: `Bearer ${token}`,
}
: undefined,
});
if (!res.ok()) {
throw new Error(
`Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`,
);
}
return res.json();
}
}
export class ClientServerApi extends Api {
constructor(baseUrl: string) {
super(`${baseUrl}/_matrix/client`);
}
public async loginUser(userId: string, password: string): Promise<Credentials> {
const json = await this.request<{
access_token: string;
user_id: string;
device_id: string;
home_server: string;
}>("POST", "/v3/login", undefined, {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: userId,
},
password: password,
});
return {
password,
accessToken: json.access_token,
userId: json.user_id,
deviceId: json.device_id,
homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"),
username: userId.slice(1).split(":")[0],
};
}
}

View File

@@ -0,0 +1,16 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/**
* Deep copy the given object. The object MUST NOT have circular references and
* MUST NOT have functions.
* @param obj - The object to deep copy.
* @returns A copy of the object without any references to the original.
*/
export function deepCopy<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}

View File

@@ -0,0 +1,19 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import * as net from "net";
export async function getFreePort(): Promise<number> {
return new Promise<number>((resolve) => {
const srv = net.createServer();
srv.listen(0, () => {
const port = (<net.AddressInfo>srv.address()).port;
srv.close(() => resolve(port));
});
});
}

View File

@@ -0,0 +1,13 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import crypto from "node:crypto";
export function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}

View File

@@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import * as http from "node:http";
import { type AddressInfo } from "node:net";
import * as http from "http";
import { type AddressInfo } from "net";
export class Webserver {
private server?: http.Server;

Binary file not shown.

View File

@@ -5,32 +5,113 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { test as base } from "@element-hq/element-web-playwright-common";
import {
type Services as BaseServices,
type WorkerOptions as BaseWorkerOptions,
} from "@element-hq/element-web-playwright-common/lib/fixtures";
import { type HomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
import { test as base } from "@playwright/test";
import { type MailpitClient } from "mailpit-api";
import { Network, type StartedNetwork } from "testcontainers";
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { type SynapseConfig, SynapseContainer } from "./testcontainers/synapse.ts";
import { Logger } from "./logger.ts";
import { type StartedMatrixAuthenticationServiceContainer } from "./testcontainers/mas.ts";
import { type HomeserverContainer, type StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts";
import { MailhogContainer, type StartedMailhogContainer } from "./testcontainers/mailpit.ts";
import { type OAuthServer } from "./plugins/oauth_server";
import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite";
import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite.ts";
import { type HomeserverType } from "./plugins/homeserver";
import { SynapseContainer } from "./testcontainers/synapse";
export interface Services extends BaseServices {
export interface TestFixtures {
mailpitClient: MailpitClient;
}
export interface Services {
logger: Logger;
network: StartedNetwork;
postgres: StartedPostgreSqlContainer;
mailpit: StartedMailhogContainer;
synapseConfig: SynapseConfig;
_homeserver: HomeserverContainer<any>;
homeserver: StartedHomeserverContainer;
// Set in masHomeserver only
mas?: StartedMatrixAuthenticationServiceContainer;
// Set in legacyOAuthHomeserver only
oAuthServer?: OAuthServer;
}
export interface WorkerOptions extends BaseWorkerOptions {
export interface Options {
homeserverType: HomeserverType;
}
export const test = base.extend<{}, Services & WorkerOptions>({
export const test = base.extend<TestFixtures, Services & Options>({
logger: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const logger = new Logger();
await use(logger);
},
{ scope: "worker" },
],
network: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const network = await new Network().start();
await use(network);
await network.stop();
},
{ scope: "worker" },
],
postgres: [
async ({ logger, network }, use) => {
const container = await new PostgreSqlContainer()
.withNetwork(network)
.withNetworkAliases("postgres")
.withLogConsumer(logger.getConsumer("postgres"))
.withTmpFs({
"/dev/shm/pgdata/data": "",
})
.withEnvironment({
PG_DATA: "/dev/shm/pgdata/data",
})
.withCommand([
"-c",
"shared_buffers=128MB",
"-c",
`fsync=off`,
"-c",
`synchronous_commit=off`,
"-c",
"full_page_writes=off",
])
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mailpit: [
async ({ logger, network }, use) => {
const container = await new MailhogContainer()
.withNetwork(network)
.withNetworkAliases("mailpit")
.withLogConsumer(logger.getConsumer("mailpit"))
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mailpitClient: async ({ mailpit: container }, use) => {
await container.client.deleteMessages();
await use(container.client);
},
synapseConfig: [{}, { scope: "worker" }],
homeserverType: ["synapse", { option: true, scope: "worker" }],
_homeserver: [
async ({ homeserverType }, use) => {
let container: HomeserverContainer<unknown>;
let container: HomeserverContainer<any>;
switch (homeserverType) {
case "synapse":
container = new SynapseContainer();
@@ -47,12 +128,46 @@ export const test = base.extend<{}, Services & WorkerOptions>({
},
{ scope: "worker" },
],
homeserver: [
async ({ homeserverType, logger, network, _homeserver: homeserver, synapseConfig, mas }, use) => {
if (homeserver instanceof SynapseContainer) {
homeserver.withConfig(synapseConfig);
}
const container = await homeserver
.withNetwork(network)
.withNetworkAliases("homeserver")
.withLogConsumer(logger.getConsumer(homeserverType))
.withMatrixAuthenticationService(mas)
.start();
context: async ({ homeserverType, synapseConfig, context, _homeserver }, use, testInfo) => {
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mas: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
// we stub the mas fixture to allow `homeserver` to depend on it to ensure
// when it is specified by `masHomeserver` it is started before the homeserver
await use(undefined);
},
{ scope: "worker" },
],
context: async (
{ homeserverType, synapseConfig, logger, context, request, _homeserver, homeserver },
use,
testInfo,
) => {
testInfo.skip(
!(_homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0,
`Test specifies Synapse config options so is unsupported with ${homeserverType}`,
);
homeserver.setRequest(request);
await logger.onTestStarted(context);
await use(context);
await logger.onTestFinished(testInfo);
await homeserver.onTestFinished(testInfo);
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Some files were not shown because too many files have changed in this diff Show More