Compare commits

..

25 Commits

Author SHA1 Message Date
R Midhun Suresh
b88a477ee4 Update snapshot 2025-01-07 15:47:20 +05:30
R Midhun Suresh
c42833dc85 Use Up/Down arrow keys to navigate through the list 2025-01-07 14:56:33 +05:30
R Midhun Suresh
3c3f05c2ba Allow flex component to take child containers props
So that we can set attributes on the container
2025-01-07 14:56:29 +05:30
R Midhun Suresh
80baac2064 Remove comment 2025-01-07 00:33:09 +05:30
R Midhun Suresh
0147e0471e Use waitFor instead of setTimeout 2025-01-07 00:31:31 +05:30
R Midhun Suresh
9ac4da3963 Update license header 2025-01-07 00:11:24 +05:30
R Midhun Suresh
64ef538f6c Merge branch 'develop' into midhun/memberlist-redesign 2025-01-07 00:07:06 +05:30
R Midhun Suresh
ec78793812 Undo translation changes that must be done via localazy 2025-01-06 15:36:06 +05:30
R Midhun Suresh
ad96434eba Fix lint error 2025-01-05 22:30:09 +05:30
R Midhun Suresh
0b28e20230 Add playwright tests 2025-01-05 20:54:00 +05:30
R Midhun Suresh
2c4d3d5084 Playwright: Make it possible to disable presence 2025-01-05 20:52:39 +05:30
R Midhun Suresh
a57d0b72c3 Add jest tests 2025-01-05 20:51:44 +05:30
R Midhun Suresh
41e20e5038 Fix broken jest tests 2025-01-05 20:50:24 +05:30
R Midhun Suresh
e6e0360608 Actually use the new memberlist view 2025-01-05 20:49:52 +05:30
R Midhun Suresh
ceb864320f Increase minimum width as per design 2025-01-05 20:49:09 +05:30
R Midhun Suresh
cf0726da9f Add/remove css files from _components.pcss 2025-01-05 20:47:29 +05:30
R Midhun Suresh
c27e2fb00f Remove old code 2025-01-05 20:46:39 +05:30
R Midhun Suresh
3561aecaa4 Add and use a new overflow component
We used the EntityTile component as a pretend overflow tile in some
places. This new lighter component is added so  that we can remove the
complex EntityTile component.
2025-01-05 20:44:29 +05:30
R Midhun Suresh
9eca64c20c Implement new memberlist view 2025-01-05 20:40:08 +05:30
R Midhun Suresh
697d15a211 Support the new memberlist in Diasambiguated profile
1. Use MemberInfo instead of RoomMember
2. CSS changes to reflect the new design
2025-01-05 20:37:06 +05:30
R Midhun Suresh
6eb51b34bf Implement new memberlist header view 2025-01-05 20:36:03 +05:30
R Midhun Suresh
4f50174aa9 Implement memberlist view model 2025-01-05 20:35:11 +05:30
R Midhun Suresh
7768795dd7 Implement new member tile 2025-01-05 20:33:54 +05:30
R Midhun Suresh
45c1bd13a4 Add new presence icon for member tile 2025-01-05 20:27:20 +05:30
R Midhun Suresh
656d3e8083 Add new e2e icon for the member tile 2025-01-05 20:21:55 +05:30
365 changed files with 8724 additions and 7744 deletions

View File

@@ -1,5 +1,5 @@
module.exports = {
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
plugins: ["matrix-org"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
parserOptions: {
project: ["./tsconfig.json"],
@@ -170,8 +170,6 @@ module.exports = {
"jsx-a11y/role-supports-aria-props": "off",
"matrix-org/require-copyright-header": "error",
"react-compiler/react-compiler": "error",
},
overrides: [
{
@@ -264,7 +262,6 @@ module.exports = {
// These are fine in tests
"no-restricted-globals": "off",
"react-compiler/react-compiler": "off",
},
},
{
@@ -274,7 +271,6 @@ module.exports = {
},
rules: {
"react-hooks/rules-of-hooks": ["off"],
"@typescript-eslint/no-floating-promises": ["error"],
},
},
{

20
.github/CODEOWNERS vendored
View File

@@ -3,21 +3,17 @@
/package.json @element-hq/element-web-team
/yarn.lock @element-hq/element-web-team
/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
# Ignore translations as those will be updated by GHA for Localazy download
/src/i18n/strings
/src/i18n/strings/en_EN.json @element-hq/element-web-reviewers
# Ignore the synapse plugin as this is updated by GHA for docker image updating
/playwright/testcontainers/synapse.ts
/playwright/plugins/homeserver/synapse/index.ts

12
.github/labels.yml vendored
View File

@@ -210,9 +210,6 @@
- name: "X-Upcoming-Release-Blocker"
description: "This does not affect the current release cycle but will affect the next one"
color: "e99695"
- name: "X-Run-All-Tests"
description: "When applied to PRs, it'll run the full gamut of end-to-end tests on the PR"
color: "ff7979"
- name: "Z-Actions"
color: "ededed"
- name: "Z-Cache-Confusion"
@@ -235,15 +232,6 @@
- name: "Z-Flaky-Test"
description: "A test is raising false alarms"
color: "ededed"
- name: "Z-Flaky-Test-Chrome"
description: "Flaky playwright test in Chrome"
color: "ededed"
- name: "Z-Flaky-Test-Firefox"
description: "Flaky playwright test in Firefox"
color: "ededed"
- name: "Z-Flaky-Test-Webkit"
description: "Flaky playwright test in Webkit"
color: "ededed"
- name: "Z-Flaky-Jest-Test"
description: "A Jest test is raising false alarms"
color: "ededed"

View File

@@ -5,9 +5,6 @@ on:
branches: [develop, master]
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# develop pushes and repository_dispatch handled in build_develop.yaml
env:
# These must be set for fetchdep.sh to get the right branch
@@ -27,17 +24,10 @@ jobs:
- macos-14
isDevelop:
- ${{ github.event_name == 'push' && github.ref_name == 'develop' }}
isPullRequest:
- ${{ github.event_name == 'pull_request' }}
# Skip the ubuntu-24.04 build for the develop branch as the dedicated CD build_develop workflow handles that
# Skip the non-linux builds for pull requests as Windows is awfully slow, so run in merge queue only
exclude:
- isDevelop: true
image: ubuntu-24.04
- isPullRequest: true
image: windows-2022
- isPullRequest: true
image: macos-14
runs-on: ${{ matrix.image }}
defaults:
run:
@@ -47,38 +37,14 @@ jobs:
- uses: actions/setup-node@v4
with:
# Disable cache on Windows as it is slower than not caching
# https://github.com/actions/setup-node/issues/975
cache: ${{ runner.os != 'Windows' && 'yarn' || '' }}
cache: "yarn"
node-version: "lts/*"
# Workaround for yarn install timeouts, especially on Windows
- run: yarn config set network-timeout 300000
- name: Fetch layered build
id: layered_build
env:
# tell layered.sh to check out the right sha of the JS-SDK & EW, if they were given one
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
run: |
scripts/layered.sh
JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD)
VECTOR_SHA=$(git rev-parse --short=12 HEAD)
echo "VERSION=$VECTOR_SHA--js-$JSSDK_SHA" >> $GITHUB_OUTPUT
- name: Copy config
run: cp element.io/develop/config.json config.json
- name: Install Dependencies
run: "./scripts/layered.sh"
- name: Build
env:
CI_PACKAGE: true
VERSION: "${{ steps.layered_build.outputs.VERSION }}"
run: |
yarn build
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: webapp-${{ matrix.image }}
path: webapp
retention-days: 1
run: "yarn build"

View File

@@ -24,10 +24,10 @@ jobs:
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3
- name: Set up QEMU
uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # v3
uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3
with:
install: true
@@ -51,7 +51,7 @@ jobs:
- name: Build and push
id: build-and-push
uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6
uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6
with:
context: .
push: true

View File

@@ -114,20 +114,14 @@ jobs:
- Chrome
- Firefox
- WebKit
- Dendrite
- Pinecone
runAllTests:
- ${{ github.event_name == 'schedule' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests') }}
# Skip the Firefox & Safari runs unless this was a cron trigger or PR has X-Run-All-Tests label
isCron:
- ${{ github.event_name == 'schedule' }}
# Skip the Firefox & Safari runs unless this was a cron trigger
exclude:
- runAllTests: false
- isCron: false
project: Firefox
- runAllTests: false
- isCron: false
project: WebKit
- runAllTests: false
project: Dendrite
- runAllTests: false
project: Pinecone
steps:
- uses: actions/checkout@v4
with:
@@ -176,7 +170,7 @@ jobs:
yarn playwright test \
--shard "${{ matrix.runner }}/${{ needs.build.outputs.num-runners }}" \
--project="${{ matrix.project }}" \
${{ (github.event_name == 'pull_request' && matrix.runAllTests == false ) && '--grep-invert @mergequeue' || '' }}
${{ github.event_name == 'pull_request' && '--grep-invert @mergequeue' || '' }}
- name: Upload blob report to GitHub Actions Artifacts
if: always()

View File

@@ -3,7 +3,7 @@
name: Upload Preview Build to Netlify
on:
workflow_run:
workflows: ["Build"]
workflows: ["End to End Tests"]
types:
- completed
jobs:
@@ -32,7 +32,7 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: webapp-ubuntu-24.04
name: webapp
path: webapp
- name: 📤 Deploy to Netlify

View File

@@ -17,13 +17,13 @@ jobs:
docker pull "$IMAGE"
INSPECT=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE")
DIGEST=${INSPECT#*@}
sed -i "s/const TAG.*/const TAG = \"develop@$DIGEST\";/" playwright/testcontainers/synapse.ts
sed -i "s/const DOCKER_TAG.*/const DOCKER_TAG = \"develop@$DIGEST\";/" playwright/plugins/homeserver/synapse/index.ts
env:
IMAGE: ghcr.io/element-hq/synapse:develop
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f # v7
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/playwright-image-updates

View File

@@ -132,3 +132,9 @@ jobs:
- name: Run linter
run: "yarn run lint:knip"
- name: Install Deps
run: "scripts/layered.sh"
- name: Dead Code Analysis
run: "yarn run analyse:unused-exports"

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@56cd38caf0615dd03f49d42ed301f1469911ac61
uses: guibranco/github-status-action-v2@d469d49426f5a7b8a1fbcac20ad274d3e4892321
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success

View File

@@ -1,6 +1,5 @@
name: Close stale flaky issues
on:
workflow_dispatch: {}
schedule:
- cron: "30 1 * * *"
permissions: {}
@@ -18,4 +17,3 @@ jobs:
days-before-close: 0
close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved."
exempt-issue-labels: "Z-Flaky-Test-Disabled"
operations-per-run: 100

View File

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

View File

@@ -1,21 +1,3 @@
Changes in [1.11.90](https://github.com/element-hq/element-web/releases/tag/v1.11.90) (2025-01-14)
==================================================================================================
## ✨ Features
* Docker: run as non-root ([#28849](https://github.com/element-hq/element-web/pull/28849)). Contributed by @richvdh.
* Docker: allow configuration of HTTP listen port via env var ([#28840](https://github.com/element-hq/element-web/pull/28840)). Contributed by @richvdh.
* Update matrix-wysiwyg to consume WASM asset ([#28838](https://github.com/element-hq/element-web/pull/28838)). Contributed by @t3chguy.
* OIDC settings tweaks ([#28787](https://github.com/element-hq/element-web/pull/28787)). Contributed by @t3chguy.
* Delabs native OIDC support ([#28615](https://github.com/element-hq/element-web/pull/28615)). Contributed by @t3chguy.
* Move room header info button to right-most position ([#28754](https://github.com/element-hq/element-web/pull/28754)). Contributed by @t3chguy.
* Enable key backup by default ([#28691](https://github.com/element-hq/element-web/pull/28691)). Contributed by @dbkr.
## 🐛 Bug Fixes
* Fix building the automations mermaid diagram ([#28881](https://github.com/element-hq/element-web/pull/28881)). Contributed by @dbkr.
* Playwright: wait for the network listener on the postgres db ([#28808](https://github.com/element-hq/element-web/pull/28808)). Contributed by @dbkr.
Changes in [1.11.89](https://github.com/element-hq/element-web/releases/tag/v1.11.89) (2024-12-18)
==================================================================================================
This is a patch release to fix a bug which could prevent loading stored crypto state from storage, and also to fix URL previews when switching back to a room.

View File

@@ -17,7 +17,6 @@ class MockMap extends EventEmitter {
setCenter = jest.fn();
setStyle = jest.fn();
fitBounds = jest.fn();
remove = jest.fn();
}
const MockMapInstance = new MockMap();

View File

@@ -23,19 +23,21 @@ element-web project is fine: leave it running it a different terminal as you wou
when developing. Alternatively if you followed the development set up from element-web then
Playwright will be capable of running the webserver on its own if it isn't already running.
The tests use [testcontainers](https://node.testcontainers.org/) to launch Homeserver (Synapse or Dendrite)
instances to test against, so you'll also need to one of the
[supported container runtimes](#supporter-container-runtimes)
installed and working in order to run the Playwright tests.
The tests use Docker to launch Homeserver (Synapse or Dendrite) instances to test against, so you'll also
need to have Docker installed and working in order to run the Playwright tests.
There are a few different ways to run the tests yourself. The simplest is to run:
```shell
docker pull ghcr.io/element-hq/synapse:develop
yarn run test:playwright
```
This will run the Playwright tests once, non-interactively.
Note: you don't need to run the `docker pull` command every time, but you should
do it regularly to ensure you are running against an up-to-date Synapse.
You can also run individual tests this way too, as you'd expect:
```shell
@@ -59,28 +61,29 @@ Some tests are excluded from running on certain browsers due to incompatibilitie
## How the Tests Work
Everything Playwright-related lives in the `playwright/` subdirectory
Everything Playwright-related lives in the `playwright/` subdirectory of react-sdk
as is typical for Playwright tests. Likewise, tests live in `playwright/e2e`.
`playwright/testcontainers` contains the testcontainers which start instances
of Synapse/Dendrite. These servers are what Element-web runs against in the tests.
`playwright/plugins/homeservers` contains Playwright plugins that starts instances
of Synapse/Dendrite in Docker containers. These servers are what Element-web runs
against in the tests.
Synapse can be launched with different configurations in order to test element
in different configurations. You can specify `synapseConfig` as such:
in different configurations. `playwright/plugins/homeserver/synapse/templates`
contains template configuration files for each different configuration.
```typescript
test.use({
synapseConfig: {
// The config options to pass to the Synapse instance
},
});
```
Each test suite can then launch whatever Synapse instances it needs in whatever
configurations.
The appropriate homeserver will be launched by the Playwright worker and reused for all tests which match the worker configuration.
Due to homeservers being reused between tests, please use unique names for any rooms put into the room directory as
they may be visible from other tests, the suggested approach is to use `testInfo.testId` within the name or lodash's uniqueId.
We remove public rooms from the room directory between tests but deleting users doesn't have a homeserver agnostic solution.
The logs from testcontainers will be attached to any reports output from Playwright.
Note that although tests should stop the Homeserver instances after running and the
plugin also stop any remaining instances after all tests have run, it is possible
to be left with some stray containers if, for example, you terminate a test such
that the `after()` does not run and also exit Playwright uncleanly. All the containers
it starts are prefixed, so they are easy to recognise. They can be removed safely.
After each test run, logs from the Synapse instances are saved in `playwright/logs/synapse`
with each instance in a separate directory named after its ID. These logs are removed
at the start of each test run.
## Writing Tests
@@ -110,6 +113,25 @@ Homeserver instances should be reasonably cheap to start (you may see the first
while as it pulls the Docker image).
You do not need to explicitly clean up the instance as it will be cleaned up by the fixture.
### Synapse Config Templates
When a Synapse instance is started, it's given a config generated from one of the config
templates in `playwright/plugins/homeserver/synapse/templates`. There are a couple of special files
in these templates:
- `homeserver.yaml`:
Template substitution happens in this file. Template variables are:
- `REGISTRATION_SECRET`: The secret used to register users via the REST API.
- `MACAROON_SECRET_KEY`: Generated each time for security
- `FORM_SECRET`: Generated each time for security
- `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at
- `localhost.signing.key`: A signing key is auto-generated and saved to this file.
Config templates should not contain a signing key and instead assume that one will exist
in this file.
All other files in the template are copied recursively to `/data/`, so the file `foo.html`
in a template can be referenced in the config as `/data/foo.html`.
### Logging In
We again heavily leverage the magic of [Playwright fixtures](https://playwright.dev/docs/test-fixtures).
@@ -205,13 +227,7 @@ has to be disabled in Playwright on Firefox & Webkit to retain routing functiona
Anything testing VoIP/microphone will need to have `@no-webkit` as fake microphone functionality is not available
there at this time.
If you wish to run all tests in a PR, you can give it the label `X-Run-All-Tests`.
## Supporter container runtimes
We use testcontainers to spin up various instances of Synapse, Matrix Authentication Service, and more.
It supports Docker out of the box but also has support for Podman, Colima, Rancher, you just need to follow some instructions to achieve it:
https://node.testcontainers.org/supported-container-runtimes/
## Colima
If you are running under Colima, you may need to set the environment variable `TMPDIR` to `/tmp/colima` or a path
within `$HOME` to allow bind mounting temporary directories into the Docker containers.

13
knip.ts
View File

@@ -10,13 +10,13 @@ export default {
"playwright/**",
"test/**",
"res/decoder-ring/**",
"res/jitsi_external_api.min.js",
"docs/**",
// Used by jest
"__mocks__/maplibre-gl.js",
],
project: ["**/*.{js,ts,jsx,tsx}"],
ignore: [
"docs/**",
"res/jitsi_external_api.min.js",
// Used by jest
"__mocks__/maplibre-gl.js",
// Keep for now
"src/hooks/useLocalStorageState.ts",
"src/components/views/elements/InfoTooltip.tsx",
@@ -37,8 +37,13 @@ export default {
// False positive
"sw.js",
// Used by webpack
"buffer",
"process",
"util",
// Used by workflows
"ts-prune",
// Required due to bug in bloom-filters https://github.com/Callidon/bloom-filters/issues/75
"@types/seedrandom",
],
ignoreBinaries: [
// Used in scripts & workflows

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.90",
"version": "1.11.89",
"description": "A feature-rich client for Matrix.org",
"author": "New Vector Ltd.",
"repository": {
@@ -66,15 +66,14 @@
"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:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts",
"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"
},
"resolutions": {
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"oidc-client-ts": "3.1.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001690",
"caniuse-lite": "1.0.30001684",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
@@ -89,7 +88,6 @@
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^8.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^2.1.0",
"@vector-im/compound-web": "^7.5.0",
"@vector-im/matrix-wysiwyg": "2.38.0",
@@ -124,7 +122,7 @@
"linkify-string": "4.2.0",
"linkifyjs": "4.2.0",
"lodash": "^4.17.21",
"maplibre-gl": "^5.0.0",
"maplibre-gl": "^4.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
@@ -137,22 +135,23 @@
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.157.2",
"qrcode": "1.5.4",
"re-resizable": "6.10.3",
"re-resizable": "6.10.1",
"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-transition-group": "^4.4.1",
"react-virtualized": "^9.22.5",
"rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3",
"sanitize-html": "2.14.0",
"sanitize-html": "2.13.1",
"tar-js": "^0.3.0",
"temporal-polyfill": "^0.2.5",
"ua-parser-js": "^1.0.2",
"uuid": "^11.0.0",
"what-input": "^5.2.10"
"what-input": "^5.2.10",
"@types/react-virtualized": "^9.21.30",
"react-virtualized": "^9.22.5"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",
@@ -181,7 +180,6 @@
"@sentry/webpack-plugin": "^2.7.1",
"@stylistic/eslint-plugin": "^2.9.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",
@@ -193,6 +191,7 @@
"@types/escape-html": "^1.0.1",
"@types/express": "^5.0.0",
"@types/file-saver": "^2.0.3",
"@types/fs-extra": "^11.0.0",
"@types/glob-to-regexp": "^0.4.1",
"@types/jest": "29.5.12",
"@types/jitsi-meet": "^2.0.2",
@@ -205,17 +204,17 @@
"@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5",
"@types/react": "18.3.18",
"@types/react": "18.3.3",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "18.3.5",
"@types/react-dom": "18.3.1",
"@types/react-transition-group": "^4.4.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",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"@typescript-eslint/parser": "^8.19.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"babel-jest": "^29.0.0",
"babel-loader": "^9.0.0",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
@@ -237,7 +236,6 @@
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "^2.0.2",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-unicorn": "^56.0.0",
"express": "^4.18.2",
@@ -245,6 +243,7 @@
"fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1",
"file-loader": "^6.0.0",
"fs-extra": "^11.0.0",
"glob": "^11.0.0",
"html-webpack-plugin": "^5.5.3",
"husky": "^9.0.0",
@@ -258,12 +257,12 @@
"lint-staged": "^15.0.2",
"mailhog": "^4.16.0",
"matrix-web-i18n": "^3.2.1",
"mini-css-extract-plugin": "2.9.2",
"mini-css-extract-plugin": "2.9.0",
"minimist": "^1.2.6",
"modernizr": "^3.12.0",
"node-fetch": "^2.6.7",
"playwright-core": "^1.45.1",
"postcss": "8.4.46",
"postcss": "8.4.38",
"postcss-easings": "^4.0.0",
"postcss-hexrgba": "2.1.0",
"postcss-import": "16.1.0",
@@ -279,22 +278,20 @@
"rimraf": "^6.0.0",
"semver": "^7.5.2",
"source-map-loader": "^5.0.0",
"strip-ansi": "^7.1.0",
"stylelint": "^16.1.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-scss": "^6.0.0",
"stylelint-value-no-unknown-custom-properties": "^6.0.1",
"terser-webpack-plugin": "^5.3.9",
"testcontainers": "^10.16.0",
"ts-node": "^10.9.1",
"ts-prune": "^0.10.3",
"typescript": "5.7.2",
"util": "^0.12.5",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^6.0.0",
"webpack-cli": "^5.0.0",
"webpack-dev-server": "^5.0.0",
"webpack-retry-chunk-load-plugin": "^3.1.1",
"webpack-version-file-plugin": "^0.5.0",
"yaml": "^2.3.3"
},

View File

@@ -8,25 +8,19 @@ Please see LICENSE files in the repository root for full details.
import { defineConfig, devices } from "@playwright/test";
import { Options } from "./playwright/services";
const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080";
const chromeProject = {
...devices["Desktop Chrome"],
channel: "chromium",
permissions: ["clipboard-write", "clipboard-read", "microphone"],
launchOptions: {
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"],
},
};
export default defineConfig<Options>({
export default defineConfig({
projects: [
{
name: "Chrome",
use: {
...chromeProject,
...devices["Desktop Chrome"],
channel: "chromium",
permissions: ["clipboard-write", "clipboard-read", "microphone"],
launchOptions: {
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"],
},
},
},
{
@@ -54,22 +48,6 @@ export default defineConfig<Options>({
},
ignoreSnapshots: true,
},
{
name: "Dendrite",
use: {
...chromeProject,
homeserverType: "dendrite",
},
ignoreSnapshots: true,
},
{
name: "Pinecone",
use: {
...chromeProject,
homeserverType: "pinecone",
},
ignoreSnapshots: true,
},
],
use: {
viewport: { width: 1280, height: 720 },

View File

@@ -123,7 +123,7 @@ test.describe("Landmark navigation tests", () => {
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Close the room
await page.goto("/#/home");
page.goto("/#/home");
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");

View File

@@ -13,8 +13,13 @@ Please see LICENSE files in the repository root for full details.
import { expect, test } from "../../element-web-test";
test.use({
synapseConfig: {
allow_guest_access: true,
startHomeserverOpts: "guest-enabled",
config: async ({ homeserver }, use) => {
await use({
default_server_config: {
"m.homeserver": { base_url: homeserver.config.baseUrl },
},
});
},
});

View File

@@ -13,14 +13,6 @@ import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { ElementAppPage } from "../../pages/ElementAppPage";
// Find and click "Reply" button
const clickButtonReply = async (tile: Locator) => {
await expect(async () => {
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
}).toPass();
};
test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test.use({
displayName: "Hanako",
@@ -230,7 +222,8 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
// Find and click "Reply" button on MessageActionBar
const tile = page.locator(".mx_EventTile_last");
await clickButtonReply(tile);
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Reply to the player with another audio file
await uploadFile(page, "playwright/sample-files/1sec.ogg");
@@ -258,12 +251,18 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const tile = page.locator(".mx_EventTile_last");
// Find and click "Reply" button
const clickButtonReply = async () => {
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
};
await uploadFile(page, "playwright/sample-files/upload-first.ogg");
// Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
await clickButtonReply(tile);
await clickButtonReply();
// Reply to the player with another audio file
await uploadFile(page, "playwright/sample-files/upload-second.ogg");
@@ -271,7 +270,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
// Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
await clickButtonReply(tile);
await clickButtonReply();
// Reply to the player with yet another audio file to create a reply chain
await uploadFile(page, "playwright/sample-files/upload-third.ogg");

View File

@@ -1,43 +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";
/*
* Tests for branding configuration
**/
test.describe("Test without branding config", () => {
test("Shows standard branding when showing the home page", async ({ pageWithCredentials: page }) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
expect(page.title()).toEqual("Element *");
});
test("Shows standard branding when showing a room", async ({ app, pageWithCredentials: page }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
expect(page.title()).toEqual("Element * | Test Room");
});
});
test.describe("Test with custom branding", () => {
test.use({
config: {
brand: "TestBrand",
},
});
test("Shows custom branding when showing the home page", async ({ pageWithCredentials: page }) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
expect(page.title()).toEqual("TestingApp TestBrand * $ignoredParameter");
});
test("Shows custom branding when showing a room", async ({ app, pageWithCredentials: page }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
expect(page.title()).toEqual("TestingApp TestBrand * Test Room $ignoredParameter");
});
});

View File

@@ -95,7 +95,7 @@ test.describe("HTML Export", () => {
async ({ page, app, room }) => {
// Set a fixed time rather than masking off the line with the time in it: we don't need to worry
// about the width changing and we can actually test this line looks correct.
await page.clock.setSystemTime(new Date("2024-01-01T00:00:00Z"));
page.clock.setSystemTime(new Date("2024-01-01T00:00:00Z"));
// Send a bunch of messages to populate the room
for (let i = 1; i < 10; i++) {

View File

@@ -165,7 +165,7 @@ test.describe("Composer", () => {
// Type another
await page.locator("div[contenteditable=true]").pressSequentially("my message 1");
// Send message
await page.locator("div[contenteditable=true]").press("Enter");
page.locator("div[contenteditable=true]").press("Enter");
// It was sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 1")).toBeVisible();
});

View File

@@ -27,7 +27,7 @@ test.describe("Create Room", () => {
// Submit
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(new RegExp(`/#/room/#test-room-1:${user.homeServer}`));
await expect(page).toHaveURL(/\/#\/room\/#test-room-1:localhost/);
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
});

View File

@@ -1,95 +0,0 @@
/*
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 { test, expect } from "../../element-web-test";
import { registerAccountMas } from "../oidc";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { TestClientServerAPI } from "../csAPI";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
// These tests register an account with MAS because then we go through the "normal" registration flow
// and crypto gets set up. Using the 'user' fixture create a user and synthesizes an existing login,
// which is faster but leaves us without crypto set up.
test.use(masHomeserver);
test.describe("Encryption state after registration", () => {
test.skip(isDendrite, "does not yet support MAS");
test("Key backup is enabled by default", async ({ page, mailhogClient, app }, testInfo) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
await app.settings.openUserSettings("Security & Privacy");
await expect(page.getByText("This session is backing up your keys.")).toBeVisible();
});
test("user is prompted to set up recovery", async ({ page, mailhogClient, app }, testInfo) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
});
});
test.describe("Key backup reset from elsewhere", () => {
test.skip(isDendrite, "does not yet support MAS");
test("Key backup is disabled when reset from elsewhere", async ({
page,
mailhogClient,
request,
homeserver,
}, testInfo) => {
const testUsername = `alice_${testInfo.testId}`;
const testPassword = "Pa$sW0rD!";
// there's a delay before keys are uploaded so the error doesn't appear immediately: use a fake
// clock so we can skip the delay
await page.clock.install();
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, testUsername, "alice@email.com", testPassword);
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();
const accessToken = await page.evaluate(() => window.mxMatrixClientPeg.get().getAccessToken());
const csAPI = new TestClientServerAPI(request, homeserver, accessToken);
const backupInfo = await csAPI.getCurrentBackupInfo();
await csAPI.deleteBackupVersion(backupInfo.version);
await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("/discardsession");
await page.getByRole("button", { name: "Send message" }).click();
await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("Message with broken key backup");
await page.getByRole("button", { name: "Send message" }).click();
// Should be the message we sent plus the room creation event
await expect(page.locator(".mx_EventTile")).toHaveCount(2);
await expect(
page.locator(".mx_RoomView_MessageList > .mx_EventTile_last .mx_EventTile_receiptSent"),
).toBeVisible();
// Wait for it to try uploading the key
await page.clock.fastForward(20000);
await expect(page.getByRole("heading", { level: 1, name: "New Recovery Method" })).toBeVisible();
});
});

View File

@@ -9,7 +9,9 @@ Please see LICENSE files in the repository root for full details.
import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { test as masTest, registerAccountMas } from "../oidc";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { TestClientServerAPI } from "../csAPI";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
@@ -19,8 +21,89 @@ async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
}
// These tests register an account with MAS because then we go through the "normal" registration flow
// and crypto gets set up. Using the 'user' fixture create a a user an synthesizes an existing login,
// which is faster but leaves us without crypto set up.
masTest.describe("Encryption state after registration", () => {
masTest.skip(isDendrite, "does not yet support MAS");
masTest("Key backup is enabled by default", async ({ page, mailhog, app }) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!");
await app.settings.openUserSettings("Security & Privacy");
await expect(page.getByText("This session is backing up your keys.")).toBeVisible();
});
masTest("user is prompted to set up recovery", async ({ page, mailhog, app }) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!");
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
});
});
masTest.describe("Key backup reset from elsewhere", () => {
masTest.skip(isDendrite, "does not yet support MAS");
masTest(
"Key backup is disabled when reset from elsewhere",
async ({ page, mailhog, request, masPrepare, homeserver }) => {
const testUsername = "alice";
const testPassword = "Pa$sW0rD!";
// there's a delay before keys are uploaded so the error doesn't appear immediately: use a fake
// clock so we can skip the delay
await page.clock.install();
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhog.api, testUsername, "alice@email.com", testPassword);
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();
// @ts-ignore - this runs in the browser scope where mxMatrixClientPeg is a thing. Here, it is not.
const accessToken = await page.evaluate(() => mxMatrixClientPeg.get().getAccessToken());
const csAPI = new TestClientServerAPI(request, homeserver, accessToken);
const backupInfo = await csAPI.getCurrentBackupInfo();
await csAPI.deleteBackupVersion(backupInfo.version);
await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("/discardsession");
await page.getByRole("button", { name: "Send message" }).click();
await page
.getByRole("textbox", { name: "Send an encrypted message…" })
.fill("Message with broken key backup");
await page.getByRole("button", { name: "Send message" }).click();
// Should be the message we sent plus the room creation event
await expect(page.locator(".mx_EventTile")).toHaveCount(2);
await expect(
page.locator(".mx_RoomView_MessageList > .mx_EventTile_last .mx_EventTile_receiptSent"),
).toBeVisible();
// Wait for it to try uploading the key
await page.clock.fastForward(20000);
await expect(page.getByRole("heading", { level: 1, name: "New Recovery Method" })).toBeVisible();
},
);
});
test.describe("Backups", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use({
displayName: "Hanako",
});

View File

@@ -8,10 +8,8 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test";
import { logIntoElement } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Complete security", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use({
displayName: "Jeff",
});
@@ -21,9 +19,9 @@ test.describe("Complete security", () => {
homeserver,
credentials,
}) => {
await logIntoElement(page, credentials);
await logIntoElement(page, homeserver, credentials);
await expect(page.getByText("Welcome Jeff", { exact: true })).toBeVisible();
});
// see also "Verify device during login with SAS" in `verification.spec.ts`.
// see also "Verify device during login with SAS" in `verifiction.spec.ts`.
});

View File

@@ -11,7 +11,6 @@ import { expect, test } from "../../element-web-test";
import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils";
import { Bot } from "../../pages/bot";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const checkDMRoom = async (page: Page) => {
const body = page.locator(".mx_RoomView_body");
@@ -68,7 +67,6 @@ const bobJoin = async (page: Page, bob: Bot) => {
};
test.describe("Cryptography", function () {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use({
displayName: "Alice",
botCreateOpts: {

View File

@@ -28,8 +28,6 @@ test.describe("Cryptography", function () {
});
test.describe("decryption failure messages", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test("should handle device-relative historical messages", async ({
homeserver,
page,
@@ -47,7 +45,7 @@ test.describe("Cryptography", function () {
await logOutOfElement(page, true);
// Log in again, and see how the message looks.
await logIntoElement(page, credentials);
await logIntoElement(page, homeserver, credentials);
await app.viewRoomByName("Test room");
const lastTile = page.locator(".mx_EventTile").last();
await expect(lastTile).toContainText("Historical messages are not available on this device");
@@ -64,7 +62,7 @@ test.describe("Cryptography", function () {
// Finally, log out again, and back in, skipping verification for now, and see what we see.
await logOutOfElement(page);
await logIntoElement(page, credentials);
await logIntoElement(page, homeserver, credentials);
await page.locator(".mx_AuthPage").getByRole("button", { name: "Skip verification for now" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click();
await app.viewRoomByName("Test room");

View File

@@ -8,10 +8,33 @@ Please see LICENSE files in the repository root for full details.
import { Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { test as base, expect, Fixtures } from "../../element-web-test";
import { viewRoomSummaryByName } from "../right-panel/utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const test = base.extend<Fixtures>({
// eslint-disable-next-line no-empty-pattern
startHomeserverOpts: async ({}, use) => {
await use("dehydration");
},
config: async ({ homeserver, context }, use) => {
const wellKnown = {
"m.homeserver": {
base_url: homeserver.config.baseUrl,
},
"org.matrix.msc3814": true,
};
await context.route("https://localhost/.well-known/matrix/client", async (route) => {
await route.fulfill({ json: wellKnown });
});
await use({
default_server_config: wellKnown,
});
},
});
const ROOM_NAME = "Test room";
const NAME = "Alice";
@@ -19,31 +42,13 @@ function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
}
test.use({
displayName: NAME,
synapseConfig: {
experimental_features: {
msc2697_enabled: false,
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.use({
displayName: NAME,
});
test("Create dehydrated device", async ({ page, user, app }, workerInfo) => {
// Create a backup (which will create SSSS, and dehydrated device)

View File

@@ -15,7 +15,6 @@ import {
awaitVerifier,
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
doTwoWaySasVerification,
logIntoElement,
waitForVerificationRequest,
@@ -29,9 +28,29 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
aliceBotClient = res.botClient;
expectedBackupVersion = res.expectedBackupVersion;
// Visit the login page of the app, to load the matrix sdk
await page.goto("/#/login");
// wait for the page to load
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
// Create a new device for alice
aliceBotClient = new Bot(page, homeserver, {
bootstrapCrossSigning: true,
bootstrapSecretStorage: true,
});
aliceBotClient.setCredentials(credentials);
// Backup is prepared in the background. Poll until it is ready.
const botClientHandle = await aliceBotClient.prepareClient();
await expect
.poll(async () => {
expectedBackupVersion = await botClientHandle.evaluate((cli) =>
cli.getCrypto()!.getActiveSessionBackupVersion(),
);
return expectedBackupVersion;
})
.not.toBe(null);
});
// Click the "Verify with another device" button, and have the bot client auto-accept it.
@@ -47,7 +66,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
}
test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials);
await logIntoElement(page, homeserver, credentials);
// Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page);
@@ -74,7 +93,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
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);
await logIntoElement(page, homeserver, credentials);
// Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page);
@@ -118,7 +137,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
});
test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials);
await logIntoElement(page, homeserver, credentials);
// Select the security phrase
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
@@ -139,7 +158,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
});
test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials);
await logIntoElement(page, homeserver, credentials);
// Select the security phrase
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
@@ -162,7 +181,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
});
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
await logIntoElement(page, credentials);
await logIntoElement(page, homeserver, credentials);
/* Dismiss "Verify this device" */
const authPage = page.locator(".mx_AuthPage");
@@ -193,17 +212,16 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
/* on the bot side, wait for the verifier to exist ... */
const verifier = await awaitVerifier(botVerificationRequest);
// ... confirm ...
void botVerificationRequest.evaluate((verificationRequest) => verificationRequest.verifier.verify());
botVerificationRequest.evaluate((verificationRequest) => verificationRequest.verifier.verify());
// ... and then check the emoji match
await doTwoWaySasVerification(page, verifier);
/* And we're all done! */
const infoDialog = page.locator(".mx_InfoDialog");
await infoDialog.getByRole("button", { name: "They match" }).click();
// We don't assert the full string as the device name is unset on Synapse but set to the user ID on Dendrite
await expect(infoDialog.getByText(`You've successfully verified`)).toContainText(
`(${aliceBotClient.credentials.deviceId})`,
);
await expect(
infoDialog.getByText(`You've successfully verified (${aliceBotClient.credentials.deviceId})!`),
).toBeVisible();
await infoDialog.getByRole("button", { name: "Got it" }).click();
});
});

View File

@@ -66,9 +66,6 @@ test.describe("Cryptography", function () {
// Bob has a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
// Dismiss the toast nagging us to set up recovery otherwise it gets in the way of clicking the room list
await page.getByRole("button", { name: "Not now" }).click();
await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: "the bird is in the hand",
@@ -210,7 +207,7 @@ test.describe("Cryptography", function () {
window.localStorage.clear();
});
await page.reload();
await logIntoElement(page, aliceCredentials, securityKey);
await logIntoElement(page, homeserver, aliceCredentials, securityKey);
/* go back to the test room and find Bob's message again */
await app.viewRoomById(testRoomId);

View File

@@ -11,7 +11,6 @@ import { bootstrapCrossSigningForClient } from "../../pages/client.ts";
/** Tests for the "invisible crypto" behaviour -- i.e., when the "exclude insecure devices" setting is enabled */
test.describe("Invisible cryptography", () => {
test.slow();
test.use({
displayName: "Alice",
botCreateOpts: { displayName: "Bob" },

View File

@@ -8,12 +8,10 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test";
import { createRoom, enableKeyBackup, logIntoElement, sendMessageInCurrentRoom } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Logout tests", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.beforeEach(async ({ page, homeserver, credentials }) => {
await logIntoElement(page, credentials);
await logIntoElement(page, homeserver, credentials);
});
test("Ask to set up recovery on logout if not setup", async ({ page, app }) => {

View File

@@ -9,24 +9,24 @@ Please see LICENSE files in the repository root for full details.
import path from "path";
import { readFile } from "node:fs/promises";
import { expect, test } from "../../element-web-test";
import { expect, Fixtures, test as base } from "../../element-web-test";
const test = base.extend<Fixtures>({
// Replace the `user` fixture with one which populates the indexeddb data before starting the app.
user: async ({ context, pageWithCredentials: page, credentials }, use) => {
await page.route(`/test_indexeddb_cryptostore_dump/*`, async (route, request) => {
const resourcePath = path.join(__dirname, new URL(request.url()).pathname);
const body = await readFile(resourcePath, { encoding: "utf-8" });
await route.fulfill({ body });
});
await page.goto("/test_indexeddb_cryptostore_dump/index.html");
await use(credentials);
},
});
test.describe("migration", { tag: "@no-webkit" }, function () {
test.use({
displayName: "Alice",
// Replace the `user` fixture with one which populates the indexeddb data before starting the app.
user: async ({ context, pageWithCredentials: page, credentials }, use) => {
await page.route(`/test_indexeddb_cryptostore_dump/*`, async (route, request) => {
const resourcePath = path.join(__dirname, new URL(request.url()).pathname);
const body = await readFile(resourcePath, { encoding: "utf-8" });
await route.fulfill({ body });
});
await page.goto("/test_indexeddb_cryptostore_dump/index.html");
await use(credentials);
},
});
test.use({ displayName: "Alice" });
test("Should support migration from legacy crypto", async ({ context, user, page }, workerInfo) => {
test.slow();

View File

@@ -74,7 +74,7 @@ test.describe("User verification", () => {
/* on the bot side, wait for the verifier to exist ... */
const botVerifier = await awaitVerifier(bobVerificationRequest);
// ... confirm ...
void botVerifier.evaluate((verifier) => verifier.verify());
botVerifier.evaluate((verifier) => verifier.verify());
// ... and then check the emoji match
await doTwoWaySasVerification(page, botVerifier);

View File

@@ -12,7 +12,6 @@ import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
import type {
CryptoEvent,
EmojiMapping,
GeneratedSecretStorageKey,
ShowSasCallbacks,
VerificationRequest,
Verifier,
@@ -23,46 +22,6 @@ import { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { Bot } from "../../pages/bot";
/**
* Create a bot client using the supplied credentials, and wait for the key backup to be ready.
* @param page - the playwright `page` fixture
* @param homeserver - the homeserver to use
* @param credentials - the credentials to use for the bot client
*/
export async function createBot(
page: Page,
homeserver: HomeserverInstance,
credentials: Credentials,
): Promise<{ botClient: Bot; recoveryKey: GeneratedSecretStorageKey; expectedBackupVersion: string }> {
// Visit the login page of the app, to load the matrix sdk
await page.goto("/#/login");
// wait for the page to load
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
// Create a new bot client
const botClient = new Bot(page, homeserver, {
bootstrapCrossSigning: true,
bootstrapSecretStorage: true,
});
botClient.setCredentials(credentials);
// Backup is prepared in the background. Poll until it is ready.
const botClientHandle = await botClient.prepareClient();
let expectedBackupVersion: string;
await expect
.poll(async () => {
expectedBackupVersion = await botClientHandle.evaluate((cli) =>
cli.getCrypto()!.getActiveSessionBackupVersion(),
);
return expectedBackupVersion;
})
.not.toBe(null);
const recoveryKey = await botClient.getRecoveryKey();
return { botClient, recoveryKey, expectedBackupVersion };
}
/**
* wait for the given client to receive an incoming verification request, and automatically accept it
*
@@ -100,7 +59,7 @@ export function handleSasVerification(verifier: JSHandle<Verifier>): Promise<Emo
return new Promise<EmojiMapping[]>((resolve) => {
const onShowSas = (event: ShowSasCallbacks) => {
verifier.off("show_sas" as VerifierEvent, onShowSas);
void event.confirm();
event.confirm();
resolve(event.sas.emoji);
};
@@ -179,9 +138,22 @@ export async function checkDeviceIsConnectedKeyBackup(
*
* If a `securityKey` is given, verifies the new device using the key.
*/
export async function logIntoElement(page: Page, credentials: Credentials, securityKey?: string) {
export async function logIntoElement(
page: Page,
homeserver: HomeserverInstance,
credentials: Credentials,
securityKey?: string,
) {
await page.goto("/#/login");
// select homeserver
await page.getByRole("button", { name: "Edit" }).click();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
// wait for the dialog to go away
await expect(page.locator(".mx_ServerPickerDialog")).not.toBeVisible();
await page.getByRole("textbox", { name: "Username" }).fill(credentials.userId);
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("button", { name: "Sign in" }).click();
@@ -354,7 +326,7 @@ export async function autoJoin(client: Client) {
await client.evaluate((cli) => {
cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => {
if (member.membership === "invite" && member.userId === cli.getUserId()) {
void cli.joinRoom(member.roomId);
cli.joinRoom(member.roomId);
}
});
});
@@ -413,25 +385,3 @@ export async function createSecondBotDevice(page: Page, homeserver: HomeserverIn
await bobSecondDevice.prepareClient();
return bobSecondDevice;
}
/**
* Remove the cached secrets from the indexedDB
* This is a workaround to simulate the case where the secrets are not cached.
*/
export async function deleteCachedSecrets(page: Page) {
await page.evaluate(async () => {
const removeCachedSecrets = new Promise((resolve) => {
const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto");
request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => {
const db = event.target.result;
const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity");
request.onsuccess = () => {
db.close();
resolve(undefined);
};
};
});
await removeCachedSecrets;
});
await page.reload();
}

View File

@@ -9,24 +9,24 @@ import { APIRequestContext } from "playwright-core";
import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { 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
* account on the homeserver independently of the client under test.
*/
export class TestClientServerAPI extends ClientServerApi {
export class TestClientServerAPI {
public constructor(
request: APIRequestContext,
homeserver: HomeserverInstance,
private request: APIRequestContext,
private homeserver: HomeserverInstance,
private accessToken: string,
) {
super(homeserver.baseUrl);
this.setRequest(request);
}
) {}
public async getCurrentBackupInfo(): Promise<KeyBackupInfo | null> {
return this.request("GET", `/v3/room_keys/version`, this.accessToken);
const res = await this.request.get(`${this.homeserver.config.baseUrl}/_matrix/client/v3/room_keys/version`, {
headers: { Authorization: `Bearer ${this.accessToken}` },
});
return await res.json();
}
/**
@@ -34,6 +34,15 @@ export class TestClientServerAPI extends ClientServerApi {
* @param version The version to delete
*/
public async deleteBackupVersion(version: string): Promise<void> {
await this.request("DELETE", `/v3/room_keys/version/${version}`, this.accessToken);
const res = await this.request.delete(
`${this.homeserver.config.baseUrl}/_matrix/client/v3/room_keys/version/${version}`,
{
headers: { Authorization: `Bearer ${this.accessToken}` },
},
);
if (!res.ok) {
throw new Error(`Failed to delete backup version: ${res.status}`);
}
}
}

View File

@@ -12,7 +12,6 @@ import type { EventType, IContent, ISendEventResponse, MsgType, Visibility } fro
import { expect, test } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { isDendrite } from "../../plugins/homeserver/dendrite";
async function sendEvent(app: ElementAppPage, roomId: string): Promise<ISendEventResponse> {
return app.client.sendEvent(roomId, null, "m.room.message" as EventType, {
@@ -32,8 +31,6 @@ function mkPadding(n: number): IContent {
}
test.describe("Editing", () => {
test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488");
// Edit "Message"
const editLastMessage = async (page: Page, edit: string) => {
const eventTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last");

View File

@@ -6,40 +6,25 @@ 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 { expect, test as base } from "../../element-web-test";
import { expect, test } from "../../element-web-test";
import { selectHomeserver } from "../utils";
import { emailHomeserver } from "../../plugins/homeserver/synapse/emailHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { Credentials } from "../../plugins/homeserver";
const username = "user1234";
// this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen.
const password = "oETo7MPf0o";
const email = "user@nowhere.dummy";
const test = base.extend<{ credentials: Pick<Credentials, "username" | "password"> }>({
// eslint-disable-next-line no-empty-pattern
credentials: async ({}, use, testInfo) => {
await use({
username: `user_${testInfo.testId}`,
// this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen.
password: "oETo7MPf0o",
});
},
});
test.use(emailHomeserver);
test.use({
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
},
});
test.describe("Forgot Password", () => {
test.skip(isDendrite, "not yet wired up");
test.use({
startHomeserverOpts: ({ mailhog }, use) =>
use({
template: "email",
variables: {
SMTP_HOST: "host.containers.internal",
SMTP_PORT: mailhog.instance.smtpPort,
},
}),
});
test("renders properly", { tag: "@screenshot" }, async ({ page, homeserver }) => {
await page.goto("/");
@@ -47,42 +32,38 @@ test.describe("Forgot Password", () => {
await page.getByRole("link", { name: "Sign in" }).click();
// need to select a homeserver at this stage, before entering the forgot password flow
await selectHomeserver(page, homeserver.baseUrl);
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("button", { name: "Forgot password?" }).click();
await expect(page.getByRole("main")).toMatchScreenshot("forgot-password.png");
});
test(
"renders email verification dialog properly",
{ tag: "@screenshot" },
async ({ page, homeserver, credentials }) => {
const user = await homeserver.registerUser(credentials.username, credentials.password);
test("renders email verification dialog properly", { tag: "@screenshot" }, async ({ page, homeserver }) => {
const user = await homeserver.registerUser(username, password);
await homeserver.setThreepid(user.userId, "email", email);
await homeserver.setThreepid(user.userId, "email", email);
await page.goto("/");
await page.goto("/");
await page.getByRole("link", { name: "Sign in" }).click();
await selectHomeserver(page, homeserver.baseUrl);
await page.getByRole("link", { name: "Sign in" }).click();
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("button", { name: "Forgot password?" }).click();
await page.getByRole("button", { name: "Forgot password?" }).click();
await page.getByRole("textbox", { name: "Email address" }).fill(email);
await page.getByRole("textbox", { name: "Email address" }).fill(email);
await page.getByRole("button", { name: "Send email" }).click();
await page.getByRole("button", { name: "Send email" }).click();
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("textbox", { name: "New Password", exact: true }).fill(credentials.password);
await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(credentials.password);
await page.getByRole("textbox", { name: "New Password", exact: true }).fill(password);
await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(password);
await page.getByRole("button", { name: "Reset password" }).click();
await page.getByRole("button", { name: "Reset password" }).click();
await expect(page.getByRole("button", { name: "Resend" })).toBeInViewport();
await expect(page.getByRole("button", { name: "Resend" })).toBeInViewport();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("forgot-password-verify-email.png");
},
);
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("forgot-password-verify-email.png");
});
});

View File

@@ -69,13 +69,29 @@ async function sendActionFromIntegrationManager(
await iframe.getByRole("button", { name: "Press to send action" }).click();
}
async function clickUntilGone(page: Page, selector: string, attempt = 0) {
if (attempt === 11) {
throw new Error("clickUntilGone attempt count exceeded");
}
await page.locator(selector).last().click();
const count = await page.locator(selector).count();
if (count > 0) {
return clickUntilGone(page, selector, ++attempt);
}
}
async function expectKickedMessage(page: Page, shouldExist: boolean) {
await expect(async () => {
await page.locator(".mx_GenericEventListSummary_toggle[aria-expanded=false]").last().click();
await expect(page.getByText(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`)).toBeVisible({
visible: shouldExist,
});
}).toPass();
// Expand any event summaries, we can't use a click multiple here because clicking one might de-render others
// This is quite horrible but seems the most stable way of clicking 0-N buttons,
// one at a time with a full re-evaluation after each click
await clickUntilGone(page, ".mx_GenericEventListSummary_toggle[aria-expanded=false]");
// Check for the event message (or lack thereof)
await expect(page.getByText(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`)).toBeVisible({
visible: shouldExist,
});
}
test.describe("Integration Manager: Kick", () => {

View File

@@ -9,10 +9,8 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test";
import { waitForRoom } from "../utils";
import { Filter } from "../../pages/Spotlight";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Create Knock Room", () => {
test.skip(isDendrite, "Dendrite does not have support for knocking");
test.use({
displayName: "Alice",
labsFlags: ["feature_ask_to_join"],
@@ -81,7 +79,6 @@ test.describe("Create Knock Room", () => {
const spotlightDialog = await app.openSpotlight();
await spotlightDialog.filter(Filter.PublicRooms);
await spotlightDialog.search("Cyber");
await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity");
});
});

View File

@@ -13,10 +13,8 @@ import { type Visibility } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
import { waitForRoom } from "../utils";
import { Filter } from "../../pages/Spotlight";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Knock Into Room", () => {
test.skip(isDendrite, "Dendrite does not have support for knocking");
test.use({
displayName: "Alice",
labsFlags: ["feature_ask_to_join"],
@@ -284,7 +282,6 @@ test.describe("Knock Into Room", () => {
const spotlightDialog = await app.openSpotlight();
await spotlightDialog.filter(Filter.PublicRooms);
await spotlightDialog.search("Cyber");
await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity");
await spotlightDialog.results.nth(0).click();

View File

@@ -10,10 +10,8 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test";
import { waitForRoom } from "../utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Manage Knocks", () => {
test.skip(isDendrite, "Dendrite does not have support for knocking");
test.use({
displayName: "Alice",
labsFlags: ["feature_ask_to_join"],
@@ -52,7 +50,7 @@ test.describe("Manage Knocks", () => {
});
test("should deny knock using bar", async ({ page, app, bot, room }) => {
await bot.knockRoom(room.roomId);
bot.knockRoom(room.roomId);
const roomKnocksBar = page.locator(".mx_RoomKnocksBar");
await expect(roomKnocksBar.getByRole("heading", { name: "Asking to join" })).toBeVisible();

View File

@@ -10,12 +10,8 @@ import { Bot } from "../../pages/bot";
import type { Locator, Page } from "@playwright/test";
import type { ElementAppPage } from "../../pages/ElementAppPage";
import { test, expect } from "../../element-web-test";
import { Credentials } from "../../plugins/homeserver";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Lazy Loading", () => {
test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488");
const charlies: Bot[] = [];
test.use({
@@ -39,18 +35,12 @@ test.describe("Lazy Loading", () => {
});
const name = "Lazy Loading Test";
const alias = "#lltest:localhost";
const charlyMsg1 = "hi bob!";
const charlyMsg2 = "how's it going??";
let roomId: string;
async function setupRoomWithBobAliceAndCharlies(
page: Page,
app: ElementAppPage,
user: Credentials,
bob: Bot,
charlies: Bot[],
) {
const alias = `#lltest:${user.homeServer}`;
async function setupRoomWithBobAliceAndCharlies(page: Page, app: ElementAppPage, bob: Bot, charlies: Bot[]) {
const visibility = await page.evaluate(() => (window as any).matrixcs.Visibility.Public);
roomId = await bob.createRoom({
name,
@@ -105,13 +95,7 @@ test.describe("Lazy Loading", () => {
}
}
async function joinCharliesWhileAliceIsOffline(
page: Page,
app: ElementAppPage,
user: Credentials,
charlies: Bot[],
) {
const alias = `#lltest:${user.homeServer}`;
async function joinCharliesWhileAliceIsOffline(page: Page, app: ElementAppPage, charlies: Bot[]) {
await app.client.network.goOffline();
for (const charly of charlies) {
await charly.joinRoom(alias);
@@ -123,19 +107,19 @@ test.describe("Lazy Loading", () => {
await app.client.waitForNextSync();
}
test("should handle lazy loading properly even when offline", async ({ page, app, bot, user }) => {
test("should handle lazy loading properly even when offline", async ({ page, app, bot }) => {
test.slow();
const charly1to5 = charlies.slice(0, 5);
const charly6to10 = charlies.slice(5);
// Set up room with alice, bob & charlies 1-5
await setupRoomWithBobAliceAndCharlies(page, app, user, bot, charly1to5);
await setupRoomWithBobAliceAndCharlies(page, app, bot, charly1to5);
// Alice should see 2 messages from every charly with the correct display name
await checkPaginatedDisplayNames(app, charly1to5);
await openMemberlist(app);
await checkMemberList(page, charly1to5);
await joinCharliesWhileAliceIsOffline(page, app, user, charly6to10);
await joinCharliesWhileAliceIsOffline(page, app, charly6to10);
await checkMemberList(page, charly6to10);
for (const charly of charlies) {

View File

@@ -7,14 +7,13 @@ Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
test.use(consentHomeserver);
test.use({
displayName: "Bob",
});
test.describe("Consent", () => {
test.use({
startHomeserverOpts: "consent",
displayName: "Bob",
});
test("should prompt the user to consent to terms when server deems it necessary", async ({
context,
page,

View File

@@ -1,29 +0,0 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { test } from "../../element-web-test";
import { doTokenRegistration } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { legacyOAuthHomeserver } from "../../plugins/homeserver/synapse/legacyOAuthHomeserver.ts";
test.use(legacyOAuthHomeserver);
// tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server
test.describe("SSO login", () => {
test.skip(isDendrite, "does not yet support SSO");
test("logs in with SSO and lands on the home screen", async ({ page, homeserver }, testInfo) => {
// If this test fails with a screen showing "Timeout connecting to remote server", it is most likely due to
// your firewall settings: Synapse is unable to reach the OIDC server.
//
// If you are using ufw, try something like:
// sudo ufw allow in on docker0
//
await doTokenRegistration(page, homeserver, testInfo);
});
});

View File

@@ -9,12 +9,11 @@ Please see LICENSE files in the repository root for full details.
import { Page } from "playwright-core";
import { expect, test } from "../../element-web-test";
import { doTokenRegistration } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { selectHomeserver } from "../utils";
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite";
// This test requires fixed credentials for the device signing keys below to work
const username = "user1234";
const password = "p4s5W0rD";
@@ -69,55 +68,26 @@ const DEVICE_SIGNING_KEYS_BODY = {
},
};
async function login(page: Page, homeserver: HomeserverInstance, credentials: Credentials) {
async function login(page: Page, homeserver: HomeserverInstance) {
await page.getByRole("link", { name: "Sign in" }).click();
await selectHomeserver(page, homeserver.baseUrl);
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("textbox", { name: "Username" }).fill(credentials.username);
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
}
// This test suite uses the same userId for all tests in the suite
// due to DEVICE_SIGNING_KEYS_BODY being specific to that userId,
// so we restart the Synapse container to make it forget everything.
test.use(consentHomeserver);
test.use({
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
},
context: async ({ context, homeserver }, use) => {
// Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts
await homeserver.restart();
await use(context);
},
credentials: async ({ context, homeserver }, use) => {
const displayName = "Dave";
const credentials = await homeserver.registerUser(username, password, displayName);
console.log(`Registered test user @user:localhost with displayname ${displayName}`);
await use({
...credentials,
displayName,
});
// Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts
await homeserver.restart();
},
});
test.describe("Login", () => {
test.describe("Password login", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use({ startHomeserverOpts: "consent" });
let creds: Credentials;
test.beforeEach(async ({ homeserver }) => {
creds = await homeserver.registerUser(username, password);
});
test("Loads the welcome page by default; then logs in with an existing account and lands on the home screen", async ({
credentials,
page,
homeserver,
checkA11y,
@@ -131,7 +101,7 @@ test.describe("Login", () => {
await page.getByRole("link", { name: "Sign in" }).click();
// first pick the homeserver, as otherwise the user picker won't be visible
await selectHomeserver(page, homeserver.baseUrl);
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("button", { name: "Edit" }).click();
@@ -144,23 +114,23 @@ test.describe("Login", () => {
await expect(page.locator(".mx_ServerPicker_server")).toHaveText("server.invalid");
// switch back to the custom homeserver
await selectHomeserver(page, homeserver.baseUrl);
await selectHomeserver(page, homeserver.config.baseUrl);
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 checkA11y();
await page.getByRole("textbox", { name: "Username" }).fill(credentials.username);
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/\/#\/home$/);
});
test("Follows the original link after login", async ({ page, homeserver, credentials }) => {
test("Follows the original link after login", async ({ page, homeserver }) => {
await page.goto("/#/room/!room:id"); // should redirect to the welcome page
await login(page, homeserver, credentials);
await login(page, homeserver);
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible();
@@ -171,19 +141,18 @@ test.describe("Login", () => {
page,
homeserver,
request,
credentials,
}) => {
const res = await request.post(`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`, {
headers: { Authorization: `Bearer ${credentials.accessToken}` },
data: DEVICE_SIGNING_KEYS_BODY,
});
const res = await request.post(
`${homeserver.config.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY },
);
if (res.status() / 100 !== 2) {
console.log("Uploading dummy keys failed", await res.json());
}
expect(res.status() / 100).toEqual(2);
await page.goto("/");
await login(page, homeserver, credentials);
await login(page, homeserver);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
@@ -201,14 +170,10 @@ test.describe("Login", () => {
page,
homeserver,
request,
credentials,
}) => {
const res = await request.post(
`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{
headers: { Authorization: `Bearer ${credentials.accessToken}` },
data: DEVICE_SIGNING_KEYS_BODY,
},
`${homeserver.config.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY },
);
if (res.status() / 100 !== 2) {
console.log("Uploading dummy keys failed", await res.json());
@@ -216,7 +181,7 @@ test.describe("Login", () => {
expect(res.status() / 100).toEqual(2);
await page.goto("/");
await login(page, homeserver, credentials);
await login(page, homeserver);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
@@ -235,15 +200,11 @@ test.describe("Login", () => {
page,
homeserver,
request,
credentials,
}) => {
console.log(`uid ${credentials.userId} body`, DEVICE_SIGNING_KEYS_BODY);
console.log(`uid ${creds.userId} body`, DEVICE_SIGNING_KEYS_BODY);
const res = await request.post(
`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{
headers: { Authorization: `Bearer ${credentials.accessToken}` },
data: DEVICE_SIGNING_KEYS_BODY,
},
`${homeserver.config.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY },
);
if (res.status() / 100 !== 2) {
console.log("Uploading dummy keys failed", await res.json());
@@ -251,9 +212,9 @@ test.describe("Login", () => {
expect(res.status() / 100).toEqual(2);
await page.goto("/");
await login(page, homeserver, credentials);
await login(page, homeserver);
const h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
const h1 = await page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
await expect(h1.locator(".mx_CompleteSecurity_skip")).toHaveCount(0);
@@ -262,7 +223,32 @@ test.describe("Login", () => {
});
});
// tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server
test.describe("SSO login", () => {
test.skip(isDendrite, "does not yet support SSO");
test.use({
startHomeserverOpts: ({ oAuthServer }, use) =>
use({
template: "default",
oAuthServerPort: oAuthServer.port,
}),
});
test("logs in with SSO and lands on the home screen", async ({ page, homeserver }) => {
// If this test fails with a screen showing "Timeout connecting to remote server", it is most likely due to
// your firewall settings: Synapse is unable to reach the OIDC server.
//
// If you are using ufw, try something like:
// sudo ufw allow in on docker0
//
await doTokenRegistration(page, homeserver);
});
});
test.describe("logout", () => {
test.use({ startHomeserverOpts: "consent" });
test("should go to login page on logout", async ({ page, user }) => {
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(user.displayName, { exact: true })).toBeVisible();
@@ -274,4 +260,29 @@ test.describe("Login", () => {
await expect(page).toHaveURL(/\/#\/login$/);
});
});
test.describe("logout with logout_redirect_url", () => {
test.use({
startHomeserverOpts: "consent",
config: {
// We redirect to decoder-ring because it's a predictable page that isn't Element itself.
// We could use example.org, matrix.org, or something else, however this puts dependency of external
// infrastructure on our tests. In the same vein, we don't really want to figure out how to ship a
// `test-landing.html` page when running with an uncontrolled Element (via `yarn start`).
// Using the decoder-ring is just as fine, and we can search for strategic names.
logout_redirect_url: "/decoder-ring/",
},
});
test("should respect logout_redirect_url", async ({ page, user }) => {
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(user.displayName, { exact: true })).toBeVisible();
// give a change for the outstanding requests queue to settle before logging out
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/decoder-ring\/$/);
});
});
});

View File

@@ -1,35 +0,0 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
test.use(consentHomeserver);
test.use({
config: {
// We redirect to decoder-ring because it's a predictable page that isn't Element itself.
// We could use example.org, matrix.org, or something else, however this puts dependency of external
// infrastructure on our tests. In the same vein, we don't really want to figure out how to ship a
// `test-landing.html` page when running with an uncontrolled Element (via `yarn start`).
// Using the decoder-ring is just as fine, and we can search for strategic names.
logout_redirect_url: "/decoder-ring/",
},
});
test.describe("logout with logout_redirect_url", () => {
test("should respect logout_redirect_url", async ({ page, user }) => {
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(user.displayName, { exact: true })).toBeVisible();
// give a change for the outstanding requests queue to settle before logging out
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/decoder-ring\/$/);
});
});

View File

@@ -13,7 +13,7 @@ test.describe("Overwrite login action", () => {
// This seems terminally flakey: https://github.com/element-hq/element-web/issues/27363
// I tried verious things to try & deflake it, to no avail: https://github.com/matrix-org/matrix-react-sdk/pull/12506
test.skip("Try replace existing login with new one", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials);
await logIntoElement(page, homeserver, credentials);
const userMenu = await app.openUserMenu();
await expect(userMenu.getByText(credentials.userId)).toBeVisible();
@@ -24,7 +24,7 @@ test.describe("Overwrite login action", () => {
expect(credentials.userId).not.toBe(bobRegister.userId);
const clientCredentials /* IMatrixClientCreds */ = {
homeserverUrl: homeserver.baseUrl,
homeserverUrl: homeserver.config.baseUrl,
...bobRegister,
};

View File

@@ -6,38 +6,119 @@ 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 { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { interceptRequestsWithSoftLogout } from "./utils";
import { doTokenRegistration } from "./utils";
import { Credentials } from "../../plugins/homeserver";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.use({
displayName: "Alice",
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
test.describe("Soft logout", () => {
test.use({
displayName: "Alice",
startHomeserverOpts: ({ oAuthServer }, use) =>
use({
template: "default",
oAuthServerPort: oAuthServer.port,
}),
});
test.describe("with password user", () => {
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByPlaceholder("Password").fill(user.password);
await page.getByPlaceholder("Password").press("Enter");
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home/);
await expect(
page.getByRole("heading", { name: "Now, let's help you get started", exact: true }),
).toBeVisible();
});
test("still shows the soft-logout page when the page is reloaded after a soft-logout", async ({
page,
user,
}) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.reload();
await expect(page.getByText("You're signed out")).toBeVisible();
});
});
test.describe("with SSO user", () => {
test.skip(isDendrite, "does not yet support SSO");
test.use({
user: async ({ page, homeserver }, use) => {
const user = await doTokenRegistration(page, homeserver);
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await use(user);
},
},
},
});
});
test.describe("Soft logout with password user", () => {
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByPlaceholder("Password").fill(user.password);
await page.getByPlaceholder("Password").press("Enter");
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home/);
await expect(page.getByRole("heading", { name: "Now, let's help you get started", exact: true })).toBeVisible();
});
await interceptRequestsWithSoftLogout(page, user);
test("still shows the soft-logout page when the page is reloaded after a soft-logout", async ({ page, user }) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.reload();
await expect(page.getByText("You're signed out")).toBeVisible();
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByRole("button", { name: "Continue with OAuth test" }).click();
// click the submit button
await page.getByRole("button", { name: "Submit" }).click();
// Synapse prompts us to grant permission to Element
await expect(page.getByRole("heading", { name: "Continue to your account" })).toBeVisible();
await page.getByRole("link", { name: "Continue" }).click();
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
});
});
});
/**
* Intercept calls to /sync and have them fail with a soft-logout
*
* Any further requests to /sync with the same access token are blocked.
*/
async function interceptRequestsWithSoftLogout(page: Page, user: Credentials): Promise<void> {
await page.route("**/_matrix/client/*/sync*", async (route, req) => {
const accessToken = await req.headerValue("Authorization");
// now, if the access token on this request matches the expired one, block it
if (accessToken === `Bearer ${user.accessToken}`) {
console.log("Intercepting request with soft-logged-out access token");
await route.fulfill({
status: 401,
json: {
errcode: "M_UNKNOWN_TOKEN",
error: "Soft logout",
soft_logout: true,
},
});
return;
}
// otherwise, pass through as normal
await route.continue();
});
const promise = page.waitForResponse((resp) => resp.url().includes("/sync") && resp.status() === 401);
// do something to make the active /sync return: create a new room
await page.evaluate(() => {
// don't wait for this to complete: it probably won't, because of the broken sync
window.mxMatrixClientPeg.get().createRoom({});
});
await promise;
}

View File

@@ -1,59 +0,0 @@
/*
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 { test, expect } from "../../element-web-test";
import { doTokenRegistration, interceptRequestsWithSoftLogout } from "./utils";
import { legacyOAuthHomeserver } from "../../plugins/homeserver/synapse/legacyOAuthHomeserver.ts";
test.use({
displayName: "Alice",
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
},
});
test.use(legacyOAuthHomeserver);
test.describe("Soft logout with SSO user", () => {
test.use({
user: async ({ page, homeserver }, use, testInfo) => {
const user = await doTokenRegistration(page, homeserver, testInfo);
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await use(user);
},
});
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByRole("button", { name: "Continue with OAuth test" }).click();
// click the submit button
await page.getByRole("button", { name: "Submit" }).click();
// Synapse prompts us to grant permission to Element
await expect(page.getByRole("heading", { name: "Continue to your account" })).toBeVisible();
await page.getByRole("link", { name: "Continue" }).click();
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
});
});

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 { Page, expect, TestInfo } from "@playwright/test";
import { Page, expect } from "@playwright/test";
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
@@ -15,13 +15,12 @@ import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
export async function doTokenRegistration(
page: Page,
homeserver: HomeserverInstance,
testInfo: TestInfo,
): Promise<Credentials & { displayName: string }> {
await page.goto("/#/login");
await page.getByRole("button", { name: "Edit" }).click();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.baseUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
await page.getByRole("button", { name: "Continue" }).click();
// wait for the dialog to go away
await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0);
@@ -36,7 +35,7 @@ export async function doTokenRegistration(
// Synapse prompts us to pick a user ID
await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible();
await page.getByRole("textbox", { name: "Username (required)" }).fill(`alice_${testInfo.testId}`);
await page.getByRole("textbox", { name: "Username (required)" }).fill("alice");
// wait for username validation to start, and complete
await expect(page.locator("#field-username-output")).toHaveText("");
@@ -57,44 +56,5 @@ export async function doTokenRegistration(
homeServer: window.mxMatrixClientPeg.get().getHomeserverUrl(),
password: null,
displayName: "Alice",
username: window.mxMatrixClientPeg.get().getUserIdLocalpart(),
}));
}
/**
* Intercept calls to /sync and have them fail with a soft-logout
*
* Any further requests to /sync with the same access token are blocked.
*/
export async function interceptRequestsWithSoftLogout(page: Page, user: Credentials): Promise<void> {
await page.route("**/_matrix/client/*/sync*", async (route, req) => {
const accessToken = await req.headerValue("Authorization");
// now, if the access token on this request matches the expired one, block it
if (accessToken === `Bearer ${user.accessToken}`) {
console.log("Intercepting request with soft-logged-out access token");
await route.fulfill({
status: 401,
json: {
errcode: "M_UNKNOWN_TOKEN",
error: "Soft logout",
soft_logout: true,
},
});
return;
}
// otherwise, pass through as normal
await route.continue();
});
const promise = page.waitForResponse((resp) => resp.url().includes("/sync") && resp.status() === 401);
// do something to make the active /sync return: create a new room
await page.evaluate(() => {
// don't wait for this to complete: it probably won't, because of the broken sync
void window.mxMatrixClientPeg.get().createRoom({});
});
await promise;
}

View File

@@ -58,16 +58,6 @@ async function editMessage(page: Page, message: Locator, newMsg: string): Promis
await editComposer.press("Enter");
}
const screenshotOptions = (page?: Page) => ({
mask: page ? [page.locator(".mx_MessageTimestamp")] : undefined,
// Hide the jump to bottom button in the timeline to avoid flakiness
css: `
.mx_JumpToBottomButton {
display: none !important;
}
`,
});
test.describe("Message rendering", () => {
[
{ direction: "ltr", displayName: "Quentin" },
@@ -89,10 +79,9 @@ test.describe("Message rendering", () => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "Hello, world!");
await expect(msgTile).toMatchScreenshot(
`basic-message-ltr-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`basic-message-ltr-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
},
);
@@ -100,17 +89,14 @@ test.describe("Message rendering", () => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me lays an egg");
await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`, screenshotOptions());
await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`);
});
test("should render an LTR rich text emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me lays a *free range* egg");
await expect(msgTile).toMatchScreenshot(
`emote-rich-ltr-${direction}displayname.png`,
screenshotOptions(),
);
await expect(msgTile).toMatchScreenshot(`emote-rich-ltr-${direction}displayname.png`);
});
test("should render an edited LTR message", async ({ page, user, app, room }) => {
@@ -120,10 +106,9 @@ test.describe("Message rendering", () => {
await editMessage(page, msgTile, "Hello, universe!");
await expect(msgTile).toMatchScreenshot(
`edited-message-ltr-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`edited-message-ltr-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render a reply of a LTR message", async ({ page, user, app, room }) => {
@@ -137,37 +122,32 @@ test.describe("Message rendering", () => {
]);
await replyMessage(page, msgTile, "response to multiline message");
await expect(msgTile).toMatchScreenshot(
`reply-message-ltr-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`reply-message-ltr-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render a basic RTL text message", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "مرحبا بالعالم!");
await expect(msgTile).toMatchScreenshot(
`basic-message-rtl-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`basic-message-rtl-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render an RTL emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me يضع بيضة");
await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`, screenshotOptions());
await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`);
});
test("should render a richtext RTL emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me أضع بيضة *حرة النطاق*");
await expect(msgTile).toMatchScreenshot(
`emote-rich-rtl-${direction}displayname.png`,
screenshotOptions(),
);
await expect(msgTile).toMatchScreenshot(`emote-rich-rtl-${direction}displayname.png`);
});
test("should render an edited RTL message", async ({ page, user, app, room }) => {
@@ -177,10 +157,9 @@ test.describe("Message rendering", () => {
await editMessage(page, msgTile, "مرحبا بالكون!");
await expect(msgTile).toMatchScreenshot(
`edited-message-rtl-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`edited-message-rtl-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render a reply of a RTL message", async ({ page, user, app, room }) => {
@@ -194,10 +173,9 @@ test.describe("Message rendering", () => {
]);
await replyMessage(page, msgTile, "مرحبا بالعالم!");
await expect(msgTile).toMatchScreenshot(
`reply-message-trl-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`reply-message-trl-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
});
});

View File

@@ -9,7 +9,61 @@ Please see LICENSE files in the repository root for full details.
import { API, Messages } from "mailhog";
import { Page } from "@playwright/test";
import { expect } from "../../element-web-test";
import { test as base, expect } from "../../element-web-test";
import { MatrixAuthenticationService } from "../../plugins/matrix-authentication-service";
import { StartHomeserverOpts } from "../../plugins/homeserver";
export const test = base.extend<{
masPrepare: MatrixAuthenticationService;
mas: MatrixAuthenticationService;
}>({
// There's a bit of a chicken and egg problem between MAS & Synapse where they each need to know how to reach each other
// so spinning up a MAS is split into the prepare & start stage: prepare mas -> homeserver -> start mas to disentangle this.
masPrepare: async ({ context }, use) => {
const mas = new MatrixAuthenticationService(context);
await mas.prepare();
await use(mas);
},
mas: [
async ({ masPrepare: mas, homeserver, mailhog }, use, testInfo) => {
await mas.start(homeserver, mailhog.instance);
await use(mas);
await mas.stop(testInfo);
},
{ auto: true },
],
startHomeserverOpts: async ({ masPrepare }, use) => {
await use({
template: "mas-oidc",
variables: {
MAS_PORT: masPrepare.port,
},
});
},
config: async ({ homeserver, startHomeserverOpts, context }, use) => {
const issuer = `http://localhost:${(startHomeserverOpts as StartHomeserverOpts).variables["MAS_PORT"]}/`;
const wellKnown = {
"m.homeserver": {
base_url: homeserver.config.baseUrl,
},
"org.matrix.msc2965.authentication": {
issuer,
account: `${issuer}account`,
},
};
// Ensure org.matrix.msc2965.authentication is in well-known
await context.route("https://localhost/.well-known/matrix/client", async (route) => {
await route.fulfill({ json: wellKnown });
});
await use({
default_server_config: wellKnown,
});
},
});
export { expect };
export async function registerAccountMas(
page: Page,
@@ -33,7 +87,7 @@ export async function registerAccountMas(
expect(messages.items).toHaveLength(1);
}).toPass();
expect(messages.items[0].to).toEqual(`${username} <${email}>`);
const [, code] = messages.items[0].text.match(/Your verification code to confirm this email address is: (\d{6})/);
const [code] = messages.items[0].text.match(/(\d{6})/);
await page.getByRole("textbox", { name: "6-digit code" }).fill(code);
await page.getByRole("button", { name: "Continue" }).click();

View File

@@ -6,39 +6,27 @@ 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, expect } from "../../element-web-test.ts";
import { registerAccountMas } from ".";
import { test, expect, registerAccountMas } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { ElementAppPage } from "../../pages/ElementAppPage.ts";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
test.use(masHomeserver);
test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test.skip(isDendrite, "does not yet support MAS");
test.slow(); // trace recording takes a while here
test("can register the oauth2 client and an account", async ({
context,
page,
homeserver,
mailhogClient,
mas,
}, testInfo) => {
await page.clock.install();
const tokenUri = `${mas.baseUrl}/oauth2/token`;
test("can register the oauth2 client and an account", async ({ context, page, homeserver, mailhog, mas }) => {
const tokenUri = `http://localhost:${mas.port}/oauth2/token`;
const tokenApiPromise = page.waitForRequest(
(request) => request.url() === tokenUri && request.postDataJSON()["grant_type"] === "authorization_code",
);
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailhogClient, userId, "alice@email.com", "Pa$sW0rD!");
await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!");
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
await expect(page.getByRole("heading", { name: `Welcome ${userId}`, exact: true })).toBeVisible();
await page.clock.runFor(20000); // run the timer so we see the token request
await expect(page.getByRole("heading", { name: "Welcome alice", exact: true })).toBeVisible();
const tokenApiRequest = await tokenApiPromise;
expect(tokenApiRequest.postDataJSON()["grant_type"]).toBe("authorization_code");
@@ -61,7 +49,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await newPage.close();
// Assert logging out revokes both tokens
const revokeUri = `${mas.baseUrl}/oauth2/revoke`;
const revokeUri = `http://localhost:${mas.port}/oauth2/revoke`;
const revokeAccessTokenPromise = page.waitForRequest(
(request) => request.url() === revokeUri && request.postDataJSON()["token_type_hint"] === "access_token",
);

View File

@@ -9,19 +9,16 @@ Please see LICENSE files in the repository root for full details.
import { test as base, expect } from "../../element-web-test";
import { Credentials } from "../../plugins/homeserver";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const test = base.extend<{
user2?: Credentials;
}>({});
test.describe("1:1 chat room", () => {
test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3492");
test.use({
displayName: "Jeff",
user2: async ({ homeserver }, use, testInfo) => {
const credentials = await homeserver.registerUser(`user2_${testInfo.testId}`, "p4s5W0rD", "Timmy");
user2: async ({ homeserver }, use) => {
const credentials = await homeserver.registerUser("user1234", "p4s5W0rD", "Timmy");
await use(credentials);
},
});

View File

@@ -31,7 +31,7 @@ test.describe("permalinks", () => {
await charlotte.prepareClient();
// We don't use a bot for danielle as we want a stable MXID.
const danielleId = `@danielle:${user.homeServer}`;
const danielleId = "@danielle:localhost";
const room1Id = await app.client.createRoom({ name: room1Name });
const room2Id = await app.client.createRoom({ name: room2Name });

View File

@@ -35,10 +35,10 @@ test.describe("Pinned messages", () => {
mask: [tile.locator(".mx_MessageTimestamp")],
// Hide the jump to bottom button in the timeline to avoid flakiness
css: `
.mx_JumpToBottomButton {
display: none !important;
}
`,
.mx_JumpToBottomButton {
display: none !important;
}
`,
});
},
);

View File

@@ -134,7 +134,7 @@ test.describe("Poll history", () => {
await expect(dialog.getByText(pollParams2.title)).toBeAttached();
await expect(dialog.getByText(pollParams1.title)).toBeAttached();
await dialog.getByText("Active polls").click();
dialog.getByText("Active polls").click();
// no more active polls
await expect(page.getByText("There are no active polls in this room")).toBeAttached();

View File

@@ -11,11 +11,8 @@ import { Bot } from "../../pages/bot";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import type { Locator, Page } from "@playwright/test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Polls", () => {
test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3492");
type CreatePollOptions = {
title: string;
options: string[];

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("editing messages", () => {
test.describe("in threads", () => {
test("An edit of a threaded message makes the room unread", async ({

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("editing messages", () => {
test.describe("in the main timeline", () => {
test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => {

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("editing messages", () => {
test.describe("thread roots", () => {
test("An edit of a thread root leaves the room read", async ({

View File

@@ -9,10 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { customEvent, many, test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.slow();
test.describe("Ignored events", () => {

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { many, test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("new messages", () => {
test.describe("in threads", () => {
test("Receiving a message makes a room unread", async ({

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { many, test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("new messages", () => {
test.describe("in the main timeline", () => {
test("Receiving a message makes a room unread", async ({

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { many, test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("new messages", () => {
test.describe("thread roots", () => {
test("Reading a thread root does not mark the thread as read", async ({

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { test, expect } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("reactions", () => {
test.describe("in threads", () => {
test("A reaction to a threaded message does not make the room unread", async ({
@@ -73,7 +70,11 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
// Given a thread exists and I have marked it as read
await util.goTo(room1);
await util.assertRead(room2);
await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]);
await util.receiveMessages(room2, [
"Msg1",
msg.threadedOff("Msg1", "Reply1"),
msg.reactionTo("Reply1", "🪿"),
]);
await util.assertUnread(room2, 1);
await util.markAsRead(room2);
await util.assertRead(room2);

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("reactions", () => {
test.describe("in the main timeline", () => {
test("Receiving a reaction to a message does not make a room unread", async ({

View File

@@ -9,10 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("reactions", () => {
test.describe("thread roots", () => {
test("A reaction to a thread root does not make the room unread", async ({

View File

@@ -12,10 +12,8 @@ import { expect } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { Bot } from "../../pages/bot";
import { test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.use({
displayName: "Mae",
botCreateOpts: { displayName: "Other User" },
@@ -102,7 +100,12 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
await page.goto(`/#/room/${selectedRoomId}`);
});
test("With sync accumulator, considers main thread and unthreaded receipts #24629", async ({ page, app, bot }) => {
// Disabled due to flakiness: https://github.com/element-hq/element-web/issues/26895
test.skip("With sync accumulator, considers main thread and unthreaded receipts #24629", async ({
page,
app,
bot,
}) => {
// Details are in https://github.com/vector-im/element-web/issues/24629
// This proves we've fixed one of the "stuck unreads" issues.

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("redactions", () => {
test.describe("in threads", () => {
test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("redactions", () => {
test.describe("in the main timeline", () => {
test("Redacting the message pointed to by my receipt leaves the room read", async ({

View File

@@ -9,11 +9,8 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { test } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Read receipts", { tag: "@mergequeue" }, () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.describe("redactions", () => {
test.describe("thread roots", () => {
test("Redacting a thread root after it was read leaves the room read", async ({

View File

@@ -7,34 +7,41 @@ Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { emailHomeserver } from "../../plugins/homeserver/synapse/emailHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.use(emailHomeserver);
test.use({
config: ({ config }, use) =>
use({
...config,
default_server_config: {
...config.default_server_config,
"m.identity_server": {
base_url: "https://server.invalid",
},
},
}),
});
test.describe("Email Registration", async () => {
test.skip(isDendrite, "not yet wired up");
test.beforeEach(async ({ homeserver, page }) => {
test.use({
startHomeserverOpts: ({ mailhog }, use) =>
use({
template: "email",
variables: {
SMTP_HOST: "host.containers.internal",
SMTP_PORT: mailhog.instance.smtpPort,
},
}),
config: ({ homeserver }, use) =>
use({
default_server_config: {
"m.homeserver": {
base_url: homeserver.config.baseUrl,
},
"m.identity_server": {
base_url: "https://server.invalid",
},
},
}),
});
test.beforeEach(async ({ page }) => {
await page.goto("/#/register");
});
test(
"registers an account and lands on the home page",
"registers an account and lands on the use case selection screen",
{ tag: "@screenshot" },
async ({ page, mailhogClient, request, checkA11y }) => {
async ({ page, mailhog, 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")] };
@@ -51,13 +58,13 @@ test.describe("Email Registration", async () => {
await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible();
const messages = await mailhogClient.messages();
const messages = await mailhog.api.messages();
expect(messages.items).toHaveLength(1);
expect(messages.items[0].to).toEqual("alice@email.com");
const [emailLink] = messages.items[0].text.match(/http.+/);
await request.get(emailLink); // "Click" the link in the email
await expect(page.getByText("Welcome alice")).toBeVisible();
await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible();
},
);
});

View File

@@ -7,24 +7,11 @@ Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.use(consentHomeserver);
test.use({
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
},
});
test.describe("Registration", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use({
startHomeserverOpts: "consent",
});
test.beforeEach(async ({ page }) => {
await page.goto("/#/register");
@@ -40,7 +27,7 @@ test.describe("Registration", () => {
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("server-picker.png");
await checkA11y();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.baseUrl);
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
// wait for the dialog to go away
await expect(page.getByRole("dialog")).not.toBeVisible();
@@ -74,6 +61,12 @@ test.describe("Registration", () => {
await expect(termsPolicy.getByLabel("Privacy Policy")).toBeVisible();
await page.getByRole("button", { name: "Accept", exact: true }).click();
await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible();
await expect(page).toMatchScreenshot("use-case-selection.png", screenshotOptions);
await checkA11y();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await expect(page).toHaveURL(/\/#\/home$/);
/*
@@ -95,7 +88,7 @@ test.describe("Registration", () => {
test("should require username to fulfil requirements and be available", async ({ homeserver, page }) => {
await page.getByRole("button", { name: "Edit", exact: true }).click();
await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.baseUrl);
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
// wait for the dialog to go away
await expect(page.getByRole("dialog")).not.toBeVisible();

View File

@@ -10,7 +10,6 @@ import { Download, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { viewRoomSummaryByName } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const ROOM_NAME = "Test room";
const NAME = "Alice";
@@ -182,8 +181,6 @@ test.describe("FilePanel", () => {
});
test.describe("download", () => {
test.skip(isDendrite, "due to a Dendrite sending Content-Disposition inline");
test("should download an image via the link on the panel", async ({ page, context }) => {
// Upload an image file
await uploadFile(page, "playwright/sample-files/riot.png");

View File

@@ -11,18 +11,13 @@ import { Bot } from "../../pages/bot";
const ROOM_NAME = "Test room";
const NAME = "Alice";
test.use({
synapseConfig: {
presence: {
enabled: false,
include_offline_users_on_sync: false,
},
},
displayName: NAME,
disablePresence: true,
});
test.describe("Memberlist", () => {
test.use({
displayName: NAME,
startHomeserverOpts: "no-presence",
disablePresence: true,
});
test.beforeEach(async ({ app, user, page, homeserver }, testInfo) => {
testInfo.setTimeout(testInfo.timeout + 30_000);
const id = await app.client.createRoom({ name: ROOM_NAME });

View File

@@ -38,34 +38,29 @@ test.describe("RightPanel", () => {
});
test.describe("in rooms", () => {
test(
"should handle long room address and long room name",
{ tag: "@screenshot" },
async ({ page, app, user }) => {
await app.client.createRoom({ name: ROOM_NAME_LONG });
await viewRoomSummaryByName(page, app, ROOM_NAME_LONG);
test("should handle long room address and long room name", { tag: "@screenshot" }, async ({ page, app }) => {
await app.client.createRoom({ name: ROOM_NAME_LONG });
await viewRoomSummaryByName(page, app, ROOM_NAME_LONG);
await app.settings.openRoomSettings();
await app.settings.openRoomSettings();
// Set a local room address
const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" });
await localAddresses.getByRole("textbox").fill(ROOM_ADDRESS_LONG);
await expect(page.getByText("This address is available to use")).toBeVisible();
await localAddresses.getByRole("button", { name: "Add" }).click();
await expect(localAddresses.getByText(`#${ROOM_ADDRESS_LONG}:${user.homeServer}`)).toHaveClass(
"mx_EditableItem_item",
);
// Set a local room address
const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" });
await localAddresses.getByRole("textbox").fill(ROOM_ADDRESS_LONG);
await localAddresses.getByRole("button", { name: "Add" }).click();
await expect(localAddresses.getByText(`#${ROOM_ADDRESS_LONG}:localhost`)).toHaveClass(
"mx_EditableItem_item",
);
await app.closeDialog();
await app.closeDialog();
// Close and reopen the right panel to render the room address
await app.toggleRoomInfoPanel();
await expect(page.locator(".mx_RightPanel")).not.toBeVisible();
await app.toggleRoomInfoPanel();
// Close and reopen the right panel to render the room address
await app.toggleRoomInfoPanel();
await expect(page.locator(".mx_RightPanel")).not.toBeVisible();
await app.toggleRoomInfoPanel();
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png");
},
);
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png");
});
test("should handle clicking add widgets", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);

View File

@@ -10,7 +10,6 @@ import type { Preset, Visibility } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
test.describe("Room Directory", () => {
test.skip(({ homeserverType }) => homeserverType === "pinecone", "Pinecone's /publicRooms API takes forever");
test.use({
displayName: "Ray",
botCreateOpts: { displayName: "Paul" },
@@ -31,16 +30,15 @@ test.describe("Room Directory", () => {
// First add a local address `gaming`
const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" });
await localAddresses.getByRole("textbox").fill("gaming");
await expect(page.getByText("This address is available to use")).toBeVisible();
await localAddresses.getByRole("button", { name: "Add" }).click();
await expect(localAddresses.getByText(`#gaming:${user.homeServer}`)).toHaveClass("mx_EditableItem_item");
await expect(localAddresses.getByText("#gaming:localhost")).toHaveClass("mx_EditableItem_item");
// Publish into the public rooms directory
const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" });
await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue(`#gaming:${user.homeServer}`);
await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue("#gaming:localhost");
const checkbox = publishedAddresses
.locator(".mx_SettingsFlag", {
hasText: `Publish this room to the public in ${user.homeServer}'s room directory?`,
hasText: "Publish this room to the public in localhost's room directory?",
})
.getByRole("switch");
await checkbox.check();
@@ -88,7 +86,7 @@ test.describe("Room Directory", () => {
.getByRole("button", { name: "Join" })
.click();
await expect(page).toHaveURL(`/#/room/#test1234:${user.homeServer}`);
await expect(page).toHaveURL("/#/room/#test1234:localhost");
},
);
});

View File

@@ -111,10 +111,6 @@ test.describe("Room Header", () => {
async ({ page, app, user }) => {
await createVideoRoom(page, app);
// Dismiss a toast that is otherwise in the way (it's the other
// side but there's no need to have it in the screenshot)
await page.getByRole("button", { name: "Later" }).click();
const header = page.locator(".mx_RoomHeader");
// There's two room info button - the header itself and the i button

View File

@@ -7,13 +7,10 @@ Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const TEST_ROOM_NAME = "The mark unread test room";
test.describe("Mark as Unread", () => {
test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970");
test.use({
displayName: "Tom",
botCreateOpts: {
@@ -51,6 +48,6 @@ test.describe("Mark as Unread", () => {
await roomTile.getByRole("button", { name: "Room options" }).click();
await page.getByRole("menuitem", { name: "Mark as unread" }).click();
await expect(page.getByLabel(TEST_ROOM_NAME + " Unread messages.")).toBeVisible();
expect(page.getByLabel(TEST_ROOM_NAME + " Unread messages.")).toBeVisible();
});
});

View File

@@ -34,14 +34,14 @@ test.describe("Account user settings tab", () => {
await expect(profile.getByRole("textbox", { name: "Display Name" })).toHaveValue(USER_NAME);
// Assert that a userId is rendered
await expect(uut.getByLabel("Username")).toHaveText(user.userId);
expect(uut.getByLabel("Username")).toHaveText(user.userId);
// Wait until spinners disappear
await expect(uut.getByTestId("accountSection").locator(".mx_Spinner")).not.toBeVisible();
await expect(uut.getByTestId("discoverySection").locator(".mx_Spinner")).not.toBeVisible();
const accountSection = uut.getByTestId("accountSection");
await accountSection.scrollIntoViewIfNeeded();
accountSection.scrollIntoViewIfNeeded();
// Assert that input areas for changing a password exists
await expect(accountSection.getByLabel("Current password")).toBeVisible();
await expect(accountSection.getByLabel("New Password")).toBeVisible();

View File

@@ -7,10 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { uiaLongSessionTimeoutHomeserver } from "../../plugins/homeserver/synapse/uiaLongSessionTimeoutHomeserver.ts";
// This is needed to not get stopped by UIA when deleting other devices
test.use(uiaLongSessionTimeoutHomeserver);
test.describe("Device manager", () => {
test.use({
displayName: "Alice",

View File

@@ -1,97 +0,0 @@
/*
* 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 { Page } from "@playwright/test";
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { ElementAppPage } from "../../../pages/ElementAppPage";
import { test as base, expect } from "../../../element-web-test";
export { expect };
/**
* Set up for the encryption tab test
*/
export const test = base.extend<{
util: Helpers;
}>({
util: async ({ page, app, bot }, use) => {
await use(new Helpers(page, app));
},
});
class Helpers {
constructor(
private page: Page,
private app: ElementAppPage,
) {}
/**
* Open the encryption tab
*/
openEncryptionTab() {
return this.app.settings.openUserSettings("Encryption");
}
/**
* Go through the device verification flow using the recovery key.
*/
async verifyDevice(recoveryKey: GeneratedSecretStorageKey) {
// Select the security phrase
await this.page.getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
await this.enterRecoveryKey(recoveryKey);
await this.page.getByRole("button", { name: "Done" }).click();
}
/**
* Fill the recovery key in the dialog
* @param recoveryKey
*/
async enterRecoveryKey(recoveryKey: GeneratedSecretStorageKey) {
// Select to use recovery key
await this.page.getByRole("button", { name: "use your Security Key" }).click();
// Fill the recovery key
const dialog = this.page.locator(".mx_Dialog");
await dialog.getByRole("textbox").fill(recoveryKey.encodedPrivateKey);
await dialog.getByRole("button", { name: "Continue" }).click();
}
/**
* Get the encryption tab content
*/
getEncryptionTabContent() {
return this.page.getByTestId("encryptionTab");
}
/**
* Set the default key id of the secret storage to `null`
*/
async removeSecretStorageDefaultKeyId() {
const client = await this.app.client.prepareClient();
await client.evaluate(async (client) => {
await client.secretStorage.setDefaultKeyId(null);
});
}
/**
* Get the security key from the clipboard and fill in the input field
* Then click on the finish button
* @param title - The title of the dialog
* @param confirmButtonLabel - The label of the confirm button
* @param screenshot
*/
async confirmRecoveryKey(title: string, confirmButtonLabel: string, screenshot: `${string}.png`) {
const dialog = this.getEncryptionTabContent();
await expect(dialog.getByText(title, { exact: true })).toBeVisible();
await expect(dialog).toMatchScreenshot(screenshot);
const clipboardContent = await this.app.getClipboard();
await dialog.getByRole("textbox").fill(clipboardContent);
await dialog.getByRole("button", { name: confirmButtonLabel }).click();
await expect(dialog).toMatchScreenshot("default-recovery.png");
}
}

View File

@@ -1,156 +0,0 @@
/*
* 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 { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { test, expect } from ".";
import {
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
deleteCachedSecrets,
verifySession,
} from "../../crypto/utils";
test.describe("Recovery section in Encryption tab", () => {
test.use({
displayName: "Alice",
});
let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion;
});
test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => {
const dialog = await util.openEncryptionTab();
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
await expect(verifyButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("verify-device-encryption-tab.png");
await verifyButton.click();
await util.verifyDevice(recoveryKey);
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await app.closeDialog();
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
});
test(
"should change the recovery key",
{ tag: ["@screenshot", "@no-webkit"] },
async ({ page, app, homeserver, credentials, util, context }) => {
await verifySession(app, "new passphrase");
const dialog = await util.openEncryptionTab();
// The user can only change the recovery key
const changeButton = dialog.getByRole("button", { name: "Change recovery key" });
await expect(changeButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
await changeButton.click();
// Display the new recovery key and click on the copy button
await expect(dialog.getByText("Change recovery key?")).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("change-key-1-encryption-tab.png", {
mask: [dialog.getByTestId("recoveryKey")],
});
await dialog.getByRole("button", { name: "Copy" }).click();
await dialog.getByRole("button", { name: "Continue" }).click();
// Confirm the recovery key
await util.confirmRecoveryKey(
"Enter your new recovery key",
"Confirm new recovery key",
"change-key-2-encryption-tab.png",
);
},
);
test("should setup the recovery key", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
await util.removeSecretStorageDefaultKeyId();
// The key backup is deleted and the user needs to set it up
const dialog = await util.openEncryptionTab();
const setupButton = dialog.getByRole("button", { name: "Set up recovery" });
await expect(setupButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-recovery.png");
await setupButton.click();
// Display an informative panel about the recovery key
await expect(dialog.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-1-encryption-tab.png");
await dialog.getByRole("button", { name: "Continue" }).click();
// Display the new recovery key and click on the copy button
await expect(dialog.getByText("Save your recovery key somewhere safe")).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-2-encryption-tab.png", {
mask: [dialog.getByTestId("recoveryKey")],
});
await dialog.getByRole("button", { name: "Copy" }).click();
await dialog.getByRole("button", { name: "Continue" }).click();
// Confirm the recovery key
await util.confirmRecoveryKey(
"Enter your recovery key to confirm",
"Finish set up",
"set-up-key-3-encryption-tab.png",
);
// The recovery key is now set up and the user can change it
await expect(dialog.getByRole("button", { name: "Change recovery key" })).toBeVisible();
await app.closeDialog();
// Check that the current device is connected to key backup and the backup version is the expected one
await checkDeviceIsConnectedKeyBackup(page, "1", true);
});
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
//
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
// We simulate this case by deleting the cached secrets in the indexedDB.
test(
"should enter the recovery key when the secrets are not cached",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
// We need to delete the cached secrets
await deleteCachedSecrets(page);
await util.openEncryptionTab();
// We ask the user to enter the recovery key
const dialog = util.getEncryptionTabContent();
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
await expect(enterKeyButton).toBeVisible();
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
await enterKeyButton.click();
// Fill the recovery key
await util.enterRecoveryKey(recoveryKey);
await expect(dialog).toMatchScreenshot("default-recovery.png");
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await app.closeDialog();
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
},
);
});

View File

@@ -36,16 +36,15 @@ test.describe("General room settings tab", () => {
await expect(settings.getByText("Show more")).toBeVisible();
});
test("long address should not cause dialog to overflow", { tag: "@no-webkit" }, async ({ page, app, user }) => {
test("long address should not cause dialog to overflow", { tag: "@no-webkit" }, async ({ page, app }) => {
const settings = await app.settings.openRoomSettings("General");
// 1. Set the room-address to be a really long string
const longString = "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksd".repeat(4);
await settings.locator("#roomAliases input[label='Room address']").fill(longString);
await expect(page.getByText("This address is available to use")).toBeVisible();
await settings.locator("#roomAliases").getByText("Add", { exact: true }).click();
// 2. wait for the new setting to apply ...
await expect(settings.locator("#canonicalAlias")).toHaveValue(`#${longString}:${user.homeServer}`);
await expect(settings.locator("#canonicalAlias")).toHaveValue(`#${longString}:localhost`);
// 3. Check if the dialog overflows
const dialogBoundingBox = await page.locator(".mx_Dialog").boundingBox();

View File

@@ -24,7 +24,7 @@ test.describe("Preferences user settings tab", () => {
});
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
await page.setViewportSize({ width: 1024, height: 3300 });
page.setViewportSize({ width: 1024, height: 3300 });
const tab = await app.settings.openUserSettings("Preferences");
// Assert that the top heading is rendered
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
@@ -61,7 +61,7 @@ test.describe("Preferences user settings tab", () => {
// Click the button to display the dropdown menu
await timezoneInput.getByRole("button", { name: "Set timezone" }).click();
// Select a different value
await timezoneInput.getByRole("option", { name: /Africa\/Abidjan/ }).click();
timezoneInput.getByRole("option", { name: /Africa\/Abidjan/ }).click();
// Check the new value
await expect(timezoneValue.getByText("Africa/Abidjan")).toBeVisible();
});

View File

@@ -23,7 +23,7 @@ test.describe("Share dialog", () => {
const dialog = page.getByRole("dialog", { name: "Share room" });
await expect(dialog.getByText(`https://matrix.to/#/${room.roomId}`)).toBeVisible();
await expect(dialog).toMatchScreenshot("share-dialog-room.png", {
expect(dialog).toMatchScreenshot("share-dialog-room.png", {
// QRCode and url changes at every run
mask: [page.locator(".mx_QRCode"), page.locator(".mx_ShareDialog_top > span")],
});
@@ -40,7 +40,7 @@ test.describe("Share dialog", () => {
const dialog = page.getByRole("dialog", { name: "Share User" });
await expect(dialog.getByText(`https://matrix.to/#/${user.userId}`)).toBeVisible();
await expect(dialog).toMatchScreenshot("share-dialog-user.png", {
expect(dialog).toMatchScreenshot("share-dialog-user.png", {
// QRCode changes at every run
mask: [page.locator(".mx_QRCode")],
});
@@ -57,7 +57,7 @@ test.describe("Share dialog", () => {
const dialog = page.getByRole("dialog", { name: "Share Room Message" });
await expect(dialog.getByRole("checkbox", { name: "Link to selected message" })).toBeChecked();
await expect(dialog).toMatchScreenshot("share-dialog-event.png", {
expect(dialog).toMatchScreenshot("share-dialog-event.png", {
// QRCode and url changes at every run
mask: [page.locator(".mx_QRCode"), page.locator(".mx_ShareDialog_top > span")],
});

View File

@@ -7,31 +7,21 @@ Please see LICENSE files in the repository root for full details.
*/
import { Page, Request } from "@playwright/test";
import { GenericContainer, 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";
import { ProxyInstance, SlidingSyncProxy } from "../../plugins/sliding-sync-proxy";
const test = base.extend<{
slidingSyncProxy: StartedTestContainer;
slidingSyncProxy: ProxyInstance;
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)}`;
slidingSyncProxy: async ({ context, page, homeserver }, use) => {
const proxy = new SlidingSyncProxy(homeserver.config.dockerUrl, context);
const proxyInstance = await proxy.start();
const proxyAddress = `http://localhost:${proxyInstance.port}`;
await page.addInitScript((proxyAddress) => {
window.localStorage.setItem(
"mx_local_settings",
@@ -41,8 +31,8 @@ const test = base.extend<{
);
window.localStorage.setItem("mx_labs_feature_feature_sliding_sync", "true");
}, proxyAddress);
await use(container);
await container.stop();
await use(proxyInstance);
await proxy.stop();
},
// Ensure slidingSyncProxy is set up before the user fixture as it relies on an init script
credentials: async ({ slidingSyncProxy, credentials }, use) => {
@@ -69,11 +59,6 @@ const test = base.extend<{
});
test.describe("Sliding Sync", () => {
test.skip(
({ homeserverType }) => homeserverType === "pinecone",
"due to a bug in Pinecone https://github.com/element-hq/dendrite/issues/3490",
);
const checkOrder = async (wantOrder: string[], page: Page) => {
await expect(page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomTile_title")).toHaveText(wantOrder);
};
@@ -113,6 +98,7 @@ test.describe("Sliding Sync", () => {
await page.getByRole("menuitemradio", { name: "A-Z" }).dispatchEvent("click");
await expect(page.locator(".mx_StyledRadioButton_checked").getByText("A-Z")).toBeVisible();
await page.pause();
await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page);
});
@@ -280,7 +266,7 @@ test.describe("Sliding Sync", () => {
// now rescind the invite
await bot.evaluate(
async (client, { roomRescind, clientUserId }) => {
await client.kick(roomRescind, clientUserId);
client.kick(roomRescind, clientUserId);
},
{ roomRescind, clientUserId },
);
@@ -299,7 +285,7 @@ test.describe("Sliding Sync", () => {
is_direct: true,
});
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite", { order: 0.5 });
client.setRoomTag(roomId, "m.favourite", { order: 0.5 });
}, roomId);
await expect(page.getByRole("group", { name: "Favourites" }).getByText("Favourite DM")).toBeVisible();
await expect(page.getByRole("group", { name: "People" }).getByText("Favourite DM")).not.toBeAttached();
@@ -375,42 +361,37 @@ test.describe("Sliding Sync", () => {
roomIds.push(id);
await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible();
}
const [roomAId, roomPId, roomOId] = roomIds;
const [roomAId, roomPId] = roomIds;
const matchRoomSubRequest = (subRoomId: string) => (request: Request) => {
if (!request.url().includes("/sync")) return false;
const assertUnsubExists = (request: Request, subRoomId: string, unsubRoomId: string) => {
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]
);
// There may be a request without a txn_id, ignore it, as there won't be any subscription changes
if (body.txn_id === undefined) {
return;
}
expect(body.unsubscribe_rooms).toEqual([unsubRoomId]);
expect(body.room_subscriptions).not.toHaveProperty(unsubRoomId);
expect(body.room_subscriptions).toHaveProperty(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;
let promise = page.waitForRequest(/sync/);
// Select the Test Room
await page.getByRole("treeitem", { name: "Apple", exact: true }).click();
// and wait for playwright to get the request
const roomSubscriptions = (await promise).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(),
]);
// Switch to another room
promise = page.waitForRequest(/sync/);
await page.getByRole("treeitem", { name: "Pineapple", exact: true }).click();
assertUnsubExists(await promise, roomPId, roomAId);
// 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(),
]);
// And switch to even another room
promise = page.waitForRequest(/sync/);
await page.getByRole("treeitem", { name: "Apple", exact: true }).click();
assertUnsubExists(await promise, roomPId, roomAId);
// TODO: Add tests for encrypted rooms
});

View File

@@ -10,7 +10,6 @@ import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import type { Preset, ICreateRoomOpts } from "matrix-js-sdk/src/matrix";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { isDendrite } from "../../plugins/homeserver/dendrite";
async function openSpaceCreateMenu(page: Page): Promise<Locator> {
await page.getByRole("button", { name: "Create a space" }).click();
@@ -51,7 +50,6 @@ function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state"
}
test.describe("Spaces", () => {
test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488");
test.use({
displayName: "Sue",
botCreateOpts: { displayName: "BotBob" },
@@ -84,7 +82,7 @@ test.describe("Spaces", () => {
// Copy matrix.to link
await page.getByRole("button", { name: "Share invite link" }).click();
expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`);
expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#lets-have-a-riot:localhost");
// Go to space home
await page.getByRole("button", { name: "Go to my first room" }).click();
@@ -171,13 +169,13 @@ test.describe("Spaces", () => {
room_alias_name: "space",
});
const menu = await openSpaceContextMenu(page, app, `#space:${user.homeServer}`);
const menu = await openSpaceContextMenu(page, app, "#space:localhost");
await menu.getByRole("menuitem", { name: "Invite" }).click();
const shareDialog = page.locator(".mx_SpacePublicShare");
// Copy link first
await shareDialog.getByRole("button", { name: "Share invite link" }).click();
expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`);
expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#space:localhost");
// Start Matrix invite flow
await shareDialog.getByRole("button", { name: "Invite people" }).click();

View File

@@ -13,7 +13,6 @@ import { test as base, expect } from "../../../element-web-test";
import { Bot } from "../../../pages/bot";
import { Client } from "../../../pages/client";
import { ElementAppPage } from "../../../pages/ElementAppPage";
import { Credentials } from "../../../plugins/homeserver";
type RoomRef = { name: string; roomId: string };
@@ -38,13 +37,11 @@ export const test = base.extend<{
room1Name: "Room 1",
room1: async ({ room1Name: name, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] });
await bot.awaitRoomMembership(roomId);
await use({ name, roomId });
},
room2Name: "Room 2",
room2: async ({ room2Name: name, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] });
await bot.awaitRoomMembership(roomId);
await use({ name, roomId });
},
msg: async ({ page, app, util }, use) => {
@@ -339,14 +336,12 @@ export class Helpers {
* @param room1
* @param room2
* @param msg - MessageBuilder
* @param user - the user to mention in the first message
* @param hasMention - whether to include a mention in the first message
*/
async populateThreads(
room1: { name: string; roomId: string },
room2: { name: string; roomId: string },
msg: MessageBuilder,
user: Credentials,
hasMention = true,
) {
if (hasMention) {
@@ -355,9 +350,9 @@ export class Helpers {
msg.threadedOff("Msg1", {
"body": "User",
"format": "org.matrix.custom.html",
"formatted_body": `<a href="https://matrix.to/#/${user.userId}">User</a>`,
"formatted_body": "<a href='https://matrix.to/#/@user:localhost'>User</a>",
"m.mentions": {
user_ids: [user.userId],
user_ids: ["@user:localhost"],
},
}),
]);

View File

@@ -8,14 +8,8 @@
import { expect, test } from ".";
import { CommandOrControl } from "../../utils";
import { isDendrite } from "../../../plugins/homeserver/dendrite";
test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
test.skip(
isDendrite,
"due to Dendrite lacking full threads support https://github.com/element-hq/dendrite/issues/3283",
);
test.use({
displayName: "Alice",
botCreateOpts: { displayName: "Other User" },
@@ -52,21 +46,16 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
await util.assertNotificationTac();
});
test("should show a highlight indicator when there is a mention in a thread", async ({
room1,
util,
msg,
user,
}) => {
test("should show a highlight indicator when there is a mention in a thread", async ({ room1, util, msg }) => {
await util.goTo(room1);
await util.receiveMessages(room1, [
"Msg1",
msg.threadedOff("Msg1", {
"body": "User",
"format": "org.matrix.custom.html",
"formatted_body": `<a href="https://matrix.to/#/${user.userId}">User</a>`,
"formatted_body": "<a href='https://matrix.to/#/@user:localhost'>User</a>",
"m.mentions": {
user_ids: [user.userId],
user_ids: ["@user:localhost"],
},
}),
]);
@@ -75,30 +64,26 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
await util.assertHighlightIndicator();
});
test(
"should show the rooms with unread threads",
{ tag: "@screenshot" },
async ({ room1, room2, util, msg, user }) => {
await util.goTo(room2);
await util.populateThreads(room1, room2, msg, user);
// The indicator should be shown
await util.assertHighlightIndicator();
// Verify that we have the expected rooms in the TAC
await util.openTac();
await util.assertRoomsInTac([
{ room: room2.name, notificationLevel: "highlight" },
{ room: room1.name, notificationLevel: "notification" },
]);
// Verify that we don't have a visual regression
await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-mix-unread.png");
},
);
test("should update with a thread is read", { tag: "@screenshot" }, async ({ room1, room2, util, msg, user }) => {
test("should show the rooms with unread threads", { tag: "@screenshot" }, async ({ room1, room2, util, msg }) => {
await util.goTo(room2);
await util.populateThreads(room1, room2, msg, user);
await util.populateThreads(room1, room2, msg);
// The indicator should be shown
await util.assertHighlightIndicator();
// Verify that we have the expected rooms in the TAC
await util.openTac();
await util.assertRoomsInTac([
{ room: room2.name, notificationLevel: "highlight" },
{ room: room1.name, notificationLevel: "notification" },
]);
// Verify that we don't have a visual regression
await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-mix-unread.png");
});
test("should update with a thread is read", { tag: "@screenshot" }, async ({ room1, room2, util, msg }) => {
await util.goTo(room2);
await util.populateThreads(room1, room2, msg);
// Click on the first room in TAC
await util.openTac();
@@ -119,9 +104,9 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-notification-unread.png");
});
test("should order by recency after notification level", async ({ room1, room2, util, msg, user }) => {
test("should order by recency after notification level", async ({ room1, room2, util, msg }) => {
await util.goTo(room2);
await util.populateThreads(room1, room2, msg, user, false);
await util.populateThreads(room1, room2, msg, false);
await util.openTac();
await util.assertRoomsInTac([

View File

@@ -6,13 +6,12 @@ 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 { AccountDataEvents, Visibility } from "matrix-js-sdk/src/matrix";
import { test as base, expect } from "../../element-web-test";
import type { AccountDataEvents } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
import { Filter } from "../../pages/Spotlight";
import { Bot } from "../../pages/bot";
import type { Locator, Page } from "@playwright/test";
import type { ElementAppPage } from "../../pages/ElementAppPage";
import { isDendrite } from "../../plugins/homeserver/dendrite";
function roomHeaderName(page: Page): Locator {
return page.locator(".mx_RoomHeader_heading");
@@ -39,37 +38,41 @@ async function startDM(app: ElementAppPage, page: Page, name: string): Promise<v
await expect(page.getByRole("group", { name: "People" }).getByText(name)).toBeAttached();
}
type RoomRef = { name: string; roomId: string };
const test = base.extend<{
bot1: Bot;
bot2: Bot;
room1: RoomRef;
room2: RoomRef;
room3: RoomRef;
}>({
bot1: async ({ page, homeserver }, use, testInfo) => {
const bot = new Bot(page, homeserver, { displayName: `BotBob_${testInfo.testId}`, autoAcceptInvites: true });
await use(bot);
},
bot2: async ({ page, homeserver }, use, testInfo) => {
const bot = new Bot(page, homeserver, { displayName: `ByteBot_${testInfo.testId}`, autoAcceptInvites: true });
await use(bot);
},
room1: async ({ app }, use) => {
const name = "247";
const roomId = await app.client.createRoom({ name, visibility: "public" as Visibility });
await use({ name, roomId });
},
room2: async ({ bot2 }, use) => {
const name = "Lounge";
const roomId = await bot2.createRoom({ name, visibility: "public" as Visibility });
await use({ name, roomId });
},
room3: async ({ bot2 }, use) => {
const name = "Public";
const roomId = await bot2.createRoom({
name,
visibility: "public" as Visibility,
test.describe("Spotlight", () => {
const bot1Name = "BotBob";
let bot1: Bot;
const bot2Name = "ByteBot";
let bot2: Bot;
const room1Name = "247";
let room1Id: string;
const room2Name = "Lounge";
let room2Id: string;
const room3Name = "Public";
let room3Id: string;
test.use({
displayName: "Jim",
});
test.beforeEach(async ({ page, homeserver, app, user }) => {
bot1 = new Bot(page, homeserver, { displayName: bot1Name, autoAcceptInvites: true });
bot2 = new Bot(page, homeserver, { displayName: bot2Name, autoAcceptInvites: true });
const Visibility = await page.evaluate(() => (window as any).matrixcs.Visibility);
room1Id = await app.client.createRoom({ name: room1Name, visibility: Visibility.Public });
await bot1.joinRoom(room1Id);
const bot1UserId = await bot1.evaluate((client) => client.getUserId());
room2Id = await bot2.createRoom({ name: room2Name, visibility: Visibility.Public });
await bot2.inviteUser(room2Id, bot1UserId);
room3Id = await bot2.createRoom({
name: room3Name,
visibility: Visibility.Public,
initial_state: [
{
type: "m.room.history_visibility",
@@ -80,27 +83,9 @@ const test = base.extend<{
},
],
});
await use({ name, roomId });
},
context: async ({ context, homeserver }, use) => {
// Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts
await homeserver.restart();
await use(context);
},
});
await bot2.inviteUser(room3Id, bot1UserId);
test.describe("Spotlight", () => {
test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488");
test.use({
displayName: "Jim",
});
test.beforeEach(async ({ page, user, bot1, bot2, room1, room2, room3 }) => {
await bot1.joinRoom(room1.roomId);
await bot2.inviteUser(room2.roomId, bot1.credentials.userId);
await bot2.inviteUser(room3.roomId, bot1.credentials.userId);
await page.goto(`/#/room/${room1.roomId}`);
await page.goto("/#/room/" + room1Id);
await expect(page.locator(".mx_RoomSublist_skeletonUI")).not.toBeAttached();
});
@@ -132,69 +117,69 @@ test.describe("Spotlight", () => {
await expect(spotlight.dialog.locator(".mx_SpotlightDialog_filter")).not.toBeAttached();
});
test("should find joined rooms", async ({ page, app, room1 }) => {
test("should find joined rooms", async ({ page, app }) => {
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.search(room1.name);
await spotlight.search(room1Name);
const resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(room1.name);
await expect(resultLocator.first()).toContainText(room1Name);
await resultLocator.first().click();
await expect(page).toHaveURL(new RegExp(`#/room/${room1.roomId}`));
await expect(roomHeaderName(page)).toContainText(room1.name);
await expect(page).toHaveURL(new RegExp(`#/room/${room1Id}`));
await expect(roomHeaderName(page)).toContainText(room1Name);
});
test("should find known public rooms", async ({ page, app, room1 }) => {
test("should find known public rooms", async ({ page, app }) => {
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.PublicRooms);
await spotlight.search(room1.name);
await spotlight.search(room1Name);
const resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(room1.name);
await expect(resultLocator.first()).toContainText(room1Name);
await expect(resultLocator.first()).toContainText("View");
await resultLocator.first().click();
await expect(page).toHaveURL(new RegExp(`#/room/${room1.roomId}`));
await expect(roomHeaderName(page)).toContainText(room1.name);
await expect(page).toHaveURL(new RegExp(`#/room/${room1Id}`));
await expect(roomHeaderName(page)).toContainText(room1Name);
});
test("should find unknown public rooms", async ({ page, app, room2 }) => {
test("should find unknown public rooms", async ({ page, app }) => {
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.PublicRooms);
await spotlight.search(room2.name);
await spotlight.search(room2Name);
const resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(room2.name);
await expect(resultLocator.first()).toContainText(room2Name);
await expect(resultLocator.first()).toContainText("Join");
await resultLocator.first().click();
await expect(page).toHaveURL(new RegExp(`#/room/${room2.roomId}`));
await expect(page).toHaveURL(new RegExp(`#/room/${room2Id}`));
await expect(page.locator(".mx_RoomView_MessageList")).toHaveCount(1);
await expect(roomHeaderName(page)).toContainText(room2.name);
await expect(roomHeaderName(page)).toContainText(room2Name);
});
test("should find unknown public world readable rooms", async ({ page, app, room3 }) => {
test("should find unknown public world readable rooms", async ({ page, app }) => {
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.PublicRooms);
await spotlight.search(room3.name);
await spotlight.search(room3Name);
const resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(room3.name);
await expect(resultLocator.first()).toContainText(room3Name);
await expect(resultLocator.first()).toContainText("View");
await resultLocator.first().click();
await expect(page).toHaveURL(new RegExp(`#/room/${room3.roomId}`));
await expect(page).toHaveURL(new RegExp(`#/room/${room3Id}`));
await page.getByRole("button", { name: "Join the discussion" }).click();
await expect(roomHeaderName(page)).toHaveText(room3.name);
await expect(roomHeaderName(page)).toHaveText(room3Name);
});
// TODO: We currently cant test finding rooms on other homeservers/other protocols
// We obviously dont have federation or bridges in local e2e tests
test.skip("should find unknown public rooms on other homeservers", async ({ page, app, room3 }) => {
test.skip("should find unknown public rooms on other homeservers", async ({ page, app }) => {
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.PublicRooms);
await spotlight.search(room3.name);
await spotlight.search(room3Name);
await page.locator("[aria-haspopup=true][role=button]").click();
await page
@@ -209,20 +194,20 @@ test.describe("Spotlight", () => {
const resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(room3.name);
await expect(resultLocator.first()).toContainText(room3.roomId);
await expect(resultLocator.first()).toContainText(room3Name);
await expect(resultLocator.first()).toContainText(room3Id);
});
test("should find known people", async ({ page, app, bot1 }) => {
test("should find known people", async ({ page, app }) => {
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.People);
await spotlight.search(bot1.credentials.displayName);
await spotlight.search(bot1Name);
const resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(bot1.credentials.displayName);
await expect(resultLocator.first()).toContainText(bot1Name);
await resultLocator.first().click();
await expect(roomHeaderName(page)).toHaveText(bot1.credentials.displayName);
await expect(roomHeaderName(page)).toHaveText(bot1Name);
});
/**
@@ -232,41 +217,42 @@ test.describe("Spotlight", () => {
*
* https://github.com/matrix-org/synapse/issues/16472
*/
test("should find unknown people", async ({ page, app, bot2 }) => {
test("should find unknown people", async ({ page, app }) => {
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.People);
await spotlight.search(bot2.credentials.displayName);
await spotlight.search(bot2Name);
const resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(bot2.credentials.displayName);
await expect(resultLocator.first()).toContainText(bot2Name);
await resultLocator.first().click();
await expect(roomHeaderName(page)).toHaveText(bot2.credentials.displayName);
await expect(roomHeaderName(page)).toHaveText(bot2Name);
});
test("should find group DMs by usernames or user ids", async ({ page, app, bot1, bot2, room1 }) => {
test("should find group DMs by usernames or user ids", async ({ page, app }) => {
// First we want to share a room with both bots to ensure weve got their usernames cached
await app.client.inviteUser(room1.roomId, bot2.credentials.userId);
const bot2UserId = await bot2.evaluate((client) => client.getUserId());
await app.client.inviteUser(room1Id, bot2UserId);
// Starting a DM with ByteBot (will be turned into a group dm later)
let spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.People);
await spotlight.search(bot2.credentials.displayName);
await spotlight.search(bot2Name);
let resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(bot2.credentials.displayName);
await expect(resultLocator.first()).toContainText(bot2Name);
await resultLocator.first().click();
// Send first message to actually start DM
await expect(roomHeaderName(page)).toHaveText(bot2.credentials.displayName);
await expect(roomHeaderName(page)).toHaveText(bot2Name);
const locator = page.getByRole("textbox", { name: "Send a message…" });
await locator.fill("Hey!");
await locator.press("Enter");
// Assert DM exists by checking for the first message and the room being in the room list
await expect(page.locator(".mx_EventTile_body").filter({ hasText: "Hey!" })).toBeAttached({ timeout: 3000 });
await expect(page.getByRole("group", { name: "People" })).toContainText(bot2.credentials.displayName);
await expect(page.getByRole("group", { name: "People" })).toContainText(bot2Name);
// Invite BotBob into existing DM with ByteBot
const dmRooms = await app.client.evaluate((client, userId) => {
@@ -274,17 +260,18 @@ test.describe("Spotlight", () => {
.getAccountData("m.direct" as keyof AccountDataEvents)
?.getContent<Record<string, string[]>>();
return map[userId] ?? [];
}, bot2.credentials.userId);
}, bot2UserId);
expect(dmRooms).toHaveLength(1);
const groupDmName = await app.client.evaluate((client, id) => client.getRoom(id).name, dmRooms[0]);
await app.client.inviteUser(dmRooms[0], bot1.credentials.userId);
const bot1UserId = await bot1.evaluate((client) => client.getUserId());
await app.client.inviteUser(dmRooms[0], bot1UserId);
await expect(roomHeaderName(page).first()).toContainText(groupDmName);
await expect(page.getByRole("group", { name: "People" }).first()).toContainText(groupDmName);
// Search for BotBob by id, should return group DM and user
spotlight = await app.openSpotlight();
await spotlight.filter(Filter.People);
await spotlight.search(bot1.credentials.userId);
await spotlight.search(bot1UserId);
await page.waitForTimeout(1000); // wait for the dialog to settle
resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(2);
@@ -297,7 +284,7 @@ test.describe("Spotlight", () => {
// Search for ByteBot by id, should return group DM and user
spotlight = await app.openSpotlight();
await spotlight.filter(Filter.People);
await spotlight.search(bot2.credentials.userId);
await spotlight.search(bot2UserId);
await page.waitForTimeout(1000); // wait for the dialog to settle
resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(2);
@@ -310,10 +297,11 @@ test.describe("Spotlight", () => {
});
// Test against https://github.com/vector-im/element-web/issues/22851
test("should show each person result only once", async ({ page, app, bot1 }) => {
test("should show each person result only once", async ({ page, app }) => {
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.People);
const bot1UserId = await bot1.evaluate((client) => client.getUserId());
// 2 rounds of search to simulate the bug conditions. Specifically, the first search
// should have 1 result (not 2) and the second search should also have 1 result (instead
@@ -322,24 +310,24 @@ test.describe("Spotlight", () => {
// We search for user ID to trigger the profile lookup within the dialog.
for (let i = 0; i < 2; i++) {
console.log("Iteration: " + i);
await spotlight.search(bot1.credentials.userId);
await spotlight.search(bot1UserId);
await page.waitForTimeout(1000); // wait for the dialog to settle
const resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(bot1.credentials.userId);
await expect(resultLocator.first()).toContainText(bot1UserId);
}
});
test("should allow opening group chat dialog", async ({ page, app, bot2 }) => {
test("should allow opening group chat dialog", async ({ page, app }) => {
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.People);
await spotlight.search(bot2.credentials.displayName);
await spotlight.search(bot2Name);
await page.waitForTimeout(3000); // wait for the dialog to settle
const resultLocator = spotlight.results;
await expect(resultLocator).toHaveCount(1);
await expect(resultLocator.first()).toContainText(bot2.credentials.displayName);
await expect(resultLocator.first()).toContainText(bot2Name);
await expect(spotlight.dialog.locator(".mx_SpotlightDialog_startGroupChat")).toContainText(
"Start a group chat",
@@ -348,18 +336,18 @@ test.describe("Spotlight", () => {
await expect(page.getByRole("dialog")).toContainText("Direct Messages");
});
test("should close spotlight after starting a DM", async ({ page, app, bot1 }) => {
await startDM(app, page, bot1.credentials.displayName);
test("should close spotlight after starting a DM", async ({ page, app }) => {
await startDM(app, page, bot1Name);
await expect(page.locator(".mx_SpotlightDialog")).toHaveCount(0);
});
test("should show the same user only once", async ({ page, app, bot1 }) => {
await startDM(app, page, bot1.credentials.displayName);
test("should show the same user only once", async ({ page, app }) => {
await startDM(app, page, bot1Name);
await page.goto("/#/home");
const spotlight = await app.openSpotlight();
await page.waitForTimeout(500); // wait for the dialog to settle
await spotlight.filter(Filter.People);
await spotlight.search(bot1.credentials.displayName);
await spotlight.search(bot1Name);
await page.waitForTimeout(3000); // wait for the dialog to settle
await expect(spotlight.dialog.locator(".mx_Spinner")).not.toBeAttached();
const resultLocator = spotlight.results;

View File

@@ -8,10 +8,8 @@ Please see LICENSE files in the repository root for full details.
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Threads", () => {
test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3489");
test.use({
displayName: "Tom",
botCreateOpts: {
@@ -26,7 +24,8 @@ test.describe("Threads", () => {
});
});
test("should be usable for a conversation", { tag: "@screenshot" }, async ({ page, app, bot }) => {
// Flaky: https://github.com/vector-im/element-web/issues/26452
test.skip("should be usable for a conversation", { tag: "@screenshot" }, async ({ page, app, bot }) => {
const roomId = await app.client.createRoom({});
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
@@ -77,7 +76,7 @@ test.describe("Threads", () => {
mask: mask,
});
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toHaveCount(2);
await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toBeVisible();
await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Initial_ThreadView_on_bubble_layout.png", {
mask: mask,
@@ -137,8 +136,8 @@ test.describe("Threads", () => {
await page.getByRole("gridcell", { name: "👋" }).click();
locator = page.locator(".mx_ThreadView");
// Make sure the CSS style for spacing is applied to mx_EventTile_footer on group/modern layout
await expect(locator.locator(".mx_EventTile[data-layout=group] .mx_EventTile_footer")).toHaveCSS(
// Make sure the CSS style for spacing is applied to mx_ReactionsRow on group/modern layout
await expect(locator.locator(".mx_EventTile[data-layout=group] .mx_ReactionsRow")).toHaveCSS(
"margin-inline-start",
ThreadViewGroupSpacingStart,
);
@@ -165,7 +164,7 @@ test.describe("Threads", () => {
locator = page.locator(
".mx_ThreadView .mx_GenericEventListSummary[data-layout=bubble] .mx_EventTile_info.mx_EventTile_last",
);
await expect(locator.locator(".mx_EventTile_line .mx_EventTile_content"))
expect(locator.locator(".mx_EventTile_line .mx_EventTile_content"))
// 76px: ThreadViewGroupSpacingStart + 14px + 6px
// 14px: avatar width
// See: _EventTile.pcss
@@ -203,14 +202,12 @@ test.describe("Threads", () => {
await locator.click();
// Wait until the response is redacted
// XXX: one would expect this redaction to be shown in the thread the message was in, but due to redactions
// stripping the thread_id, it is instead shown in the main timeline
await expect(page.locator(".mx_MainSplit_timeline").locator(".mx_EventTile_last")).toContainText(
"Message deleted",
);
await expect(
page.locator(".mx_ThreadView").locator(".mx_EventTile_last .mx_EventTile_receiptSent"),
).toBeVisible();
// Take snapshots in group layout and bubble layout (IRC layout is not available on ThreadView)
await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='group']")).toHaveCount(2);
await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='group']")).toBeVisible();
await expect(page.locator(".mx_ThreadView")).toMatchScreenshot(
"ThreadView_with_redacted_messages_on_group_layout.png",
{
@@ -218,7 +215,7 @@ test.describe("Threads", () => {
},
);
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toHaveCount(2);
await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toBeVisible();
await expect(page.locator(".mx_ThreadView")).toMatchScreenshot(
"ThreadView_with_redacted_messages_on_bubble_layout.png",
{
@@ -236,8 +233,8 @@ test.describe("Threads", () => {
// User closes right panel after clicking back to thread list
locator = page.locator(".mx_ThreadPanel");
await locator.getByRole("button", { name: "Threads" }).click();
await locator.getByRole("button", { name: "Close" }).click();
locator.getByRole("button", { name: "Threads" }).click();
locator.getByRole("button", { name: "Close" }).click();
// Bot responds to thread
await bot.sendMessage(roomId, "How are things?", threadId);
@@ -246,8 +243,9 @@ test.describe("Threads", () => {
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached();
await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached();
locator = page.getByRole("banner").getByRole("button", { name: "Threads" });
await expect(locator).toHaveAttribute("data-indicator", "success"); // User asserts thread list unread indicator
locator = page.getByRole("button", { name: "Threads" });
await expect(locator).toHaveAttribute("data-indicator", "default"); // User asserts thread list unread indicator
// await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/);
await locator.click(); // User opens thread list
// User asserts thread with correct root & latest events & unread dot
@@ -275,18 +273,20 @@ test.describe("Threads", () => {
await expect(locator.getByText("Great!")).toBeAttached();
await locator.locator(".mx_EventTile_line").hover();
await locator.locator(".mx_EventTile_line").getByRole("button", { name: "Edit" }).click();
await locator.getByRole("textbox").pressSequentially(" How about yourself?"); // fill would overwrite the original text
await locator.getByRole("textbox").fill(" How about yourself?{enter}");
await locator.getByRole("textbox").press("Enter");
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached();
await expect(locator.locator(".mx_ThreadSummary_content")).toHaveText("Great! How about yourself?");
await expect(
locator.locator(".mx_ThreadSummary_content").getByText("Great! How about yourself?"),
).toBeAttached();
// User closes right panel
await page.locator(".mx_ThreadPanel").getByRole("button", { name: "Close" }).click();
// Bot responds to thread and saves the id of their message to @eventId
const { event_id: eventId } = await bot.sendMessage(roomId, "I'm very good thanks", threadId);
const { event_id: eventId } = await bot.sendMessage(roomId, threadId, "I'm very good thanks");
// User asserts
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
@@ -344,7 +344,7 @@ test.describe("Threads", () => {
await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1);
await (await app.openMessageComposerOptions(true)).getByRole("menuitem", { name: "Voice Message" }).click();
(await app.openMessageComposerOptions(true)).getByRole("menuitem", { name: "Voice Message" }).click();
await page.waitForTimeout(3000);
await app.getComposer(true).getByRole("button", { name: "Send voice message" }).click();
await expect(page.locator(".mx_ThreadView .mx_MVoiceMessageBody")).toHaveCount(1);

View File

@@ -590,6 +590,10 @@ test.describe("Timeline", () => {
"should set inline start padding to a hidden event line",
{ tag: "@screenshot" },
async ({ page, app, room }) => {
test.skip(
true,
"Disabled due to screenshot test being flaky - https://github.com/element-hq/element-web/issues/26890",
);
await sendEvent(app.client, room.roomId);
await page.goto(`/#/room/${room.roomId}`);
await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true);
@@ -603,12 +607,7 @@ test.describe("Timeline", () => {
await messageEdit(page);
// Click timestamp to highlight hidden event line
const timestamp = page.locator(".mx_RoomView_body .mx_EventTile_info a", {
has: page.locator(".mx_MessageTimestamp"),
});
// wait for the remote echo otherwise we get an error modal due to a 404 on the /event/ API
await expect(timestamp).not.toHaveAttribute("href", /~!/);
await timestamp.locator(".mx_MessageTimestamp").click();
await page.locator(".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp").click();
// should not add inline start padding to a hidden event line on IRC layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
@@ -1195,7 +1194,6 @@ test.describe("Timeline", () => {
});
await sendImage(app.client, room.roomId, NEW_AVATAR);
await app.timeline.scrollToBottom();
await expect(page.locator(".mx_MImageBody").first()).toBeVisible();
// Exclude timestamp and read marker from snapshot

View File

@@ -0,0 +1,79 @@
/*
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 { test, expect } from "../../element-web-test";
test.describe("User Onboarding (new user)", () => {
test.use({
displayName: "Jane Doe",
});
// This first beforeEach happens before the `user` fixture runs
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("mx_registration_time", "1656633601");
});
});
test.beforeEach(async ({ page, user }) => {
await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible();
await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible();
await expect(page.locator(".mx_UserOnboardingList")).toBeVisible();
});
test("page is shown and preference exists", { tag: "@screenshot" }, async ({ page, app }) => {
await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot(
"User-Onboarding-new-user-page-is-shown-and-preference-exists-1.png",
);
await app.settings.openUserSettings("Preferences");
await expect(page.getByText("Show shortcut to welcome checklist above the room list")).toBeVisible();
});
test("app download dialog", { tag: "@screenshot" }, async ({ page }) => {
await page.getByRole("button", { name: "Download apps" }).click();
await expect(
page.getByRole("dialog").getByRole("heading", { level: 1, name: "Download Element" }),
).toBeVisible();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot(
"User-Onboarding-new-user-app-download-dialog-1.png",
{
// Set a constant bg behind the modal to ensure screenshot stability
css: `
.mx_AppDownloadDialog_wrapper {
background: black;
}
`,
},
);
});
test("using find friends action should increase progress", async ({ page, homeserver }) => {
const bot = await homeserver.registerUser("botbob", "password", "BotBob");
const oldProgress = parseFloat(await page.getByRole("progressbar").getAttribute("value"));
await page.getByRole("button", { name: "Find friends" }).click();
await page.locator(".mx_InviteDialog_editor").getByRole("textbox").fill(bot.userId);
await page.getByRole("button", { name: "Go" }).click();
await expect(page.locator(".mx_InviteDialog_buttonAndSpinner")).not.toBeVisible();
const message = "Hi!";
const composer = page.getByRole("textbox", { name: "Send a message…" });
await composer.fill(`${message}`);
await composer.press("Enter");
await expect(page.locator(".mx_MTextBody.mx_EventTile_content", { hasText: message })).toBeVisible();
await page.goto("/#/home");
await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible();
await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible();
await expect(page.locator(".mx_UserOnboardingList")).toBeVisible();
await page.waitForTimeout(500); // await progress bar animation
const progress = parseFloat(await page.getByRole("progressbar").getAttribute("value"));
expect(progress).toBeGreaterThan(oldProgress);
});
});

View File

@@ -0,0 +1,28 @@
/*
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 { test, expect } from "../../element-web-test";
test.describe("User Onboarding (old user)", () => {
test.use({
displayName: "Jane Doe",
});
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("mx_registration_time", "2");
});
});
test("page and preference are hidden", async ({ page, user, app }) => {
await expect(page.locator(".mx_UserOnboardingPage")).not.toBeVisible();
await expect(page.locator(".mx_UserOnboardingButton")).not.toBeVisible();
await app.settings.openUserSettings("Preferences");
await expect(page.getByText("Show shortcut to welcome checklist above the room list")).not.toBeVisible();
});
});

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