Merge matrix-react-sdk into element-web

Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

View file

@ -1,16 +1,8 @@
# Copyright 2024 New Vector Ltd.
# Copyright 2017 Aviral Dasgupta
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
# Please see LICENSE files in the repository root for full details.
root = true
@ -27,3 +19,6 @@ indent_size = 4
[package.json]
indent_size = 2
[*.tsx.snap]
trim_trailing_whitespace = false

View file

@ -1,4 +1,8 @@
src/vector/modernizr.js
test/end-to-end-tests/node_modules/
test/end-to-end-tests/element/
test/end-to-end-tests/synapse/
test/end-to-end-tests/lib/
# Legacy skinning file that some people might still have
src/component-index.js
# Auto-generated file

View file

@ -45,24 +45,12 @@ module.exports = {
name: "matrix-js-sdk/src/index",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-react-sdk",
message: "Please use matrix-react-sdk/src/index instead",
},
{
name: "matrix-react-sdk/",
message: "Please use matrix-react-sdk/src/index instead",
},
],
patterns: [
{
group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"],
message: "Please use matrix-js-sdk/src/* instead",
},
{
group: ["matrix-react-sdk/lib", "matrix-react-sdk/lib/", "matrix-react-sdk/lib/**"],
message: "Please use matrix-react-sdk/src/* instead",
},
],
},
],

View file

@ -1,6 +1,6 @@
module.exports = {
plugins: ["matrix-org"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
parserOptions: {
project: ["./tsconfig.json"],
},
@ -8,36 +8,53 @@ module.exports = {
browser: true,
node: true,
},
globals: {
LANGUAGES_FILE: "readonly",
},
rules: {
// Things we do that break the ideal style
quotes: "off",
},
settings: {
react: {
version: "detect",
},
},
overrides: [
{
files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "scripts/*.ts"],
extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"],
// NOTE: These rules are frozen and new rules should not be added here.
// New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/
rules: {
// Things we do that break the ideal style
"no-constant-condition": "off",
"prefer-promise-reject-errors": "off",
"quotes": "off",
"no-async-promise-executor": "off",
"no-extra-boolean-cast": "off",
// We disable this while we're transitioning
"@typescript-eslint/no-explicit-any": "off",
// We're okay with assertion errors when we ask for them
"@typescript-eslint/no-non-null-assertion": "off",
// Bind or arrow functions in props causes performance issues (but we
// currently use them in some places).
// It's disabled here, but we should using it sparingly.
"react/jsx-no-bind": "off",
"react/jsx-key": ["error"],
"no-restricted-properties": [
"error",
...buildRestrictedPropertiesOptions(
["window.innerHeight", "window.innerWidth", "window.visualViewport"],
"Use UIStore to access window dimensions instead.",
),
...buildRestrictedPropertiesOptions(
["*.mxcUrlToHttp", "*.getHttpUriForMxc"],
"Use Media helper instead to centralise access for customisation.",
),
...buildRestrictedPropertiesOptions(["window.setImmediate"], "Use setTimeout instead."),
],
"no-restricted-globals": [
"error",
{
name: "setImmediate",
message: "Use setTimeout instead.",
},
],
"import/no-duplicates": ["error"],
// Ban matrix-js-sdk/src imports in favour of matrix-js-sdk/src/matrix imports to prevent unleashing hell.
// Ban compound-design-tokens raw svg imports in favour of their React component counterparts
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@testing-library/react",
message: "Please use jest-matrix-react instead",
},
{
name: "matrix-js-sdk",
message: "Please use matrix-js-sdk/src/matrix instead",
@ -59,37 +76,213 @@ module.exports = {
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-react-sdk",
message: "Please use matrix-react-sdk/src/index instead",
},
{
name: "matrix-react-sdk/",
message: "Please use matrix-react-sdk/src/index instead",
name: "emojibase-regex",
message:
"This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.",
},
],
patterns: [
{
group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"],
message: "Please use matrix-js-sdk/src/* instead",
group: [
"matrix-js-sdk/src/**",
"!matrix-js-sdk/src/matrix",
"!matrix-js-sdk/src/crypto-api",
"!matrix-js-sdk/src/types",
"!matrix-js-sdk/src/testing",
"!matrix-js-sdk/src/utils/**",
"matrix-js-sdk/src/utils/internal/**",
"matrix-js-sdk/lib",
"matrix-js-sdk/lib/",
"matrix-js-sdk/lib/**",
// XXX: Temporarily allow these as they are not available via the main export
"!matrix-js-sdk/src/logger",
"!matrix-js-sdk/src/errors",
"!matrix-js-sdk/src/utils",
"!matrix-js-sdk/src/version-support",
"!matrix-js-sdk/src/randomstring",
"!matrix-js-sdk/src/sliding-sync",
"!matrix-js-sdk/src/browser-index",
"!matrix-js-sdk/src/feature",
"!matrix-js-sdk/src/NamespacedValue",
"!matrix-js-sdk/src/ReEmitter",
"!matrix-js-sdk/src/event-mapper",
"!matrix-js-sdk/src/interactive-auth",
"!matrix-js-sdk/src/secret-storage",
"!matrix-js-sdk/src/room-hierarchy",
"!matrix-js-sdk/src/rendezvous",
"!matrix-js-sdk/src/indexeddb-worker",
"!matrix-js-sdk/src/pushprocessor",
"!matrix-js-sdk/src/extensible_events_v1",
"!matrix-js-sdk/src/extensible_events_v1/PollStartEvent",
"!matrix-js-sdk/src/extensible_events_v1/PollResponseEvent",
"!matrix-js-sdk/src/extensible_events_v1/PollEndEvent",
"!matrix-js-sdk/src/extensible_events_v1/InvalidEventError",
"!matrix-js-sdk/src/crypto",
"!matrix-js-sdk/src/crypto/keybackup",
"!matrix-js-sdk/src/crypto/deviceinfo",
"!matrix-js-sdk/src/crypto/dehydration",
"!matrix-js-sdk/src/oidc",
"!matrix-js-sdk/src/oidc/discovery",
"!matrix-js-sdk/src/oidc/authorize",
"!matrix-js-sdk/src/oidc/validate",
"!matrix-js-sdk/src/oidc/error",
"!matrix-js-sdk/src/oidc/register",
"!matrix-js-sdk/src/webrtc",
"!matrix-js-sdk/src/webrtc/call",
"!matrix-js-sdk/src/webrtc/callFeed",
"!matrix-js-sdk/src/webrtc/mediaHandler",
"!matrix-js-sdk/src/webrtc/callEventTypes",
"!matrix-js-sdk/src/webrtc/callEventHandler",
"!matrix-js-sdk/src/webrtc/groupCallEventHandler",
"!matrix-js-sdk/src/models",
"!matrix-js-sdk/src/models/read-receipt",
"!matrix-js-sdk/src/models/relations-container",
"!matrix-js-sdk/src/models/related-relations",
"!matrix-js-sdk/src/matrixrtc",
],
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
group: ["matrix-react-sdk/lib", "matrix-react-sdk/lib/", "matrix-react-sdk/lib/**"],
message: "Please use matrix-react-sdk/src/* instead",
group: ["emojibase-regex/emoji*"],
message:
"This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.",
},
{
group: ["@vector-im/compound-design-tokens/icons/*"],
message: "Please use @vector-im/compound-design-tokens/assets/web/icons/* instead",
},
],
},
],
// There are too many a11y violations to fix at once
// Turn violated rules off until they are fixed
"jsx-a11y/aria-activedescendant-has-tabindex": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/interactive-supports-focus": "off",
"jsx-a11y/media-has-caption": "off",
"jsx-a11y/mouse-events-have-key-events": "off",
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/no-noninteractive-element-to-interactive-role": "off",
"jsx-a11y/no-noninteractive-tabindex": "off",
"jsx-a11y/no-static-element-interactions": "off",
"jsx-a11y/role-supports-aria-props": "off",
"matrix-org/require-copyright-header": "error",
},
overrides: [
{
files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "playwright/**/*.ts"],
extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"],
rules: {
"@typescript-eslint/explicit-function-return-type": [
"error",
{
allowExpressions: true,
},
],
// Things we do that break the ideal style
"prefer-promise-reject-errors": "off",
"no-extra-boolean-cast": "off",
// Remove Babel things manually due to override limitations
"@babel/no-invalid-this": ["off"],
// We're okay being explicit at the moment
"@typescript-eslint/no-empty-interface": "off",
// We disable this while we're transitioning
"@typescript-eslint/no-explicit-any": "off",
// We'd rather not do this but we do
"@typescript-eslint/ban-ts-comment": "off",
// We're okay with assertion errors when we ask for them
"@typescript-eslint/no-non-null-assertion": "off",
},
},
// temporary override for offending icon require files
{
files: [
"src/SdkConfig.ts",
"src/components/structures/FileDropTarget.tsx",
"src/components/structures/RoomStatusBar.tsx",
"src/components/structures/UserMenu.tsx",
"src/components/views/avatars/WidgetAvatar.tsx",
"src/components/views/dialogs/AddExistingToSpaceDialog.tsx",
"src/components/views/dialogs/ForwardDialog.tsx",
"src/components/views/dialogs/InviteDialog.tsx",
"src/components/views/dialogs/ModalWidgetDialog.tsx",
"src/components/views/dialogs/UploadConfirmDialog.tsx",
"src/components/views/dialogs/security/SetupEncryptionDialog.tsx",
"src/components/views/elements/AddressTile.tsx",
"src/components/views/elements/AppWarning.tsx",
"src/components/views/elements/SSOButtons.tsx",
"src/components/views/messages/MAudioBody.tsx",
"src/components/views/messages/MImageBody.tsx",
"src/components/views/messages/MFileBody.tsx",
"src/components/views/messages/MStickerBody.tsx",
"src/components/views/messages/MVideoBody.tsx",
"src/components/views/messages/MVoiceMessageBody.tsx",
"src/components/views/right_panel/EncryptionPanel.tsx",
"src/components/views/rooms/EntityTile.tsx",
"src/components/views/rooms/LinkPreviewGroup.tsx",
"src/components/views/rooms/MemberList.tsx",
"src/components/views/rooms/MessageComposer.tsx",
"src/components/views/rooms/ReplyPreview.tsx",
"src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx",
"src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx",
],
rules: {
"@typescript-eslint/no-var-requires": "off",
},
},
{
files: ["test/**/*.{ts,tsx}"],
files: ["test/**/*.{ts,tsx}", "playwright/**/*.ts"],
extends: ["plugin:matrix-org/jest"],
rules: {
// We don't need super strict typing in test utilities
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-floating-promises": "off",
// Jest/Playwright specific
// Disabled tests are a reality for now but as soon as all of the xits are
// eliminated, we should enforce this.
"jest/no-disabled-tests": "off",
// Also treat "oldBackendOnly" as a test function.
// Used in some crypto tests.
"jest/no-standalone-expect": [
"error",
{
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"],
},
],
},
},
{
files: ["playwright/**/*.ts"],
parserOptions: {
project: ["./playwright/tsconfig.json"],
},
},
],
settings: {
react: {
version: "detect",
},
},
};
function buildRestrictedPropertiesOptions(properties, message) {
return properties.map((prop) => {
let [object, property] = prop.split(".");
if (object === "*") {
object = undefined;
}
return {
object,
property,
message,
};
});
}

View file

@ -1,2 +1,3 @@
# prettier
526645c79160ab1ad4b4c3845de27d51263a405e
7921a6cbf86b035d2b0c1daecb4c24beaf5a5abc

13
.github/CODEOWNERS vendored
View file

@ -2,4 +2,17 @@
/.github/workflows/** @element-hq/element-web-team
/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
# Ignore translations as those will be updated by GHA for Localazy download
/src/i18n/strings
# Ignore the synapse plugin as this is updated by GHA for docker image updating
/playwright/plugins/homeserver/synapse/index.ts

View file

@ -5,4 +5,4 @@
- [ ] Tests written for new code (and old code if feasible).
- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation.
- [ ] Linter and other CI checks pass.
- [ ] Sign-off given on the changes (see [CONTRIBUTING.md](https://github.com/element-hq/element-web/blob/develop/CONTRIBUTING.md)).
- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-web)

View file

@ -1,3 +1,3 @@
_extends: element-hq/matrix-react-sdk
_extends: matrix-org/matrix-js-sdk
version-resolver:
default: patch

View file

@ -30,12 +30,6 @@ jobs:
with:
path: element-web
- name: Fetch matrix-react-sdk
uses: actions/checkout@v4
with:
repository: element-hq/matrix-react-sdk
path: matrix-react-sdk
- name: Fetch matrix-js-sdk
uses: actions/checkout@v4
with:
@ -52,7 +46,7 @@ jobs:
working-directory: element-web
run: |
yarn install --frozen-lockfile
yarn ts-node ./scripts/gen-workflow-mermaid.ts ../element-desktop ../element-web ../matrix-react-sdk ../matrix-js-sdk > docs/automations.md
yarn ts-node ./scripts/gen-workflow-mermaid.ts ../element-desktop ../element-web ../matrix-js-sdk > docs/automations.md
echo "- [Automations](automations.md)" >> docs/SUMMARY.md
- name: Setup mdBook
@ -74,9 +68,6 @@ jobs:
mv element-web/docs/lib docs/
mv element-web/docs "docs/Element Web"
mv matrix-react-sdk/README.md matrix-react-sdk/docs/
mv matrix-react-sdk/docs "docs/Matrix React SDK"
mv matrix-js-sdk/README.md matrix-js-sdk/docs/
mv matrix-js-sdk/docs "docs/Matrix JS SDK"

View file

@ -0,0 +1,43 @@
# Triggers after the playwright tests have finished,
# taking the artifact and uploading it to Netlify for easier viewing
name: Upload End to End Test report to Netlify
on:
workflow_run:
workflows: ["End to End Tests"]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
jobs:
report:
if: github.event.workflow_run.conclusion != 'cancelled'
name: Report results
runs-on: ubuntu-22.04
environment: Netlify
permissions:
statuses: write
deployments: write
steps:
- name: Download HTML report
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: html-report
path: playwright-report
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v3
with:
path: playwright-report
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ secrets.NETLIFY_SITE_ID }}
desc: Playwright Report
deployment_env: EndToEndTests
prefix: "e2e-"

View file

@ -1,29 +1,202 @@
# Triggers after the "Downstream artifacts" build has finished, to run the
# matrix-react-sdk playwright tests (with access to repo secrets)
name: matrix-react-sdk End to End Tests
# Produce a build of element-web with this version of react-sdk
# and any matching branches of element-web and js-sdk, output it
# as an artifact and run end-to-end tests.
name: End to End Tests
on:
pull_request: {}
merge_group:
types: [checks_requested]
pull_request: {}
push:
branches: [develop, master]
repository_dispatch:
types: [upstream-sdk-notify]
# support triggering from other workflows
workflow_call:
inputs:
skip:
type: boolean
required: false
default: false
description: "A boolean to skip the playwright check itself while still creating the passing check. Useful when only running in Merge Queues."
matrix-js-sdk-sha:
type: string
required: false
description: "The Git SHA of matrix-js-sdk to build against. By default, will use a matching branch name if it exists, or develop."
element-web-sha:
type: string
required: false
description: "The Git SHA of element-web to build against. By default, will use a matching branch name if it exists, or develop."
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
env:
# fetchdep.sh needs to know our PR number
PR_NUMBER: ${{ github.event.pull_request.number }}
jobs:
build:
name: "Build Element-Web"
runs-on: ubuntu-22.04
if: inputs.skip != true
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
repository: element-hq/element-web
- uses: actions/setup-node@v4
with:
cache: "yarn"
node-version: "lts/*"
- 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 }}
ELEMENT_WEB_GITHUB_BASE_REF: ${{ inputs.element-web-sha }}
run: |
scripts/ci/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
working-directory: ./element-web
- name: Build
env:
CI_PACKAGE: true
VERSION: "${{ steps.layered_build.outputs.VERSION }}"
run: |
yarn build
echo $VERSION > webapp/version
working-directory: ./element-web
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: webapp
path: element-web/webapp
retention-days: 1
playwright:
name: Playwright
uses: element-hq/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml@develop
name: "Run Tests ${{ matrix.runner }}/${{ strategy.job-total }}"
needs: build
if: inputs.skip != true
runs-on: ubuntu-22.04
permissions:
actions: read
issues: read
pull-requests: read
strategy:
fail-fast: false
matrix:
# Run multiple instances in parallel to speed up the tests
runner: [1, 2, 3, 4, 5, 6]
steps:
- uses: actions/checkout@v4
with:
element-web-sha: ${{ github.sha }}
react-sdk-repository: element-hq/matrix-react-sdk
# We only want to run the playwright tests on merge queue to prevent regressions
# from creeping in. They take a long time to run and consume multiple concurrent runners.
skip: ${{ github.event_name != 'merge_group' }}
persist-credentials: false
path: element-web
repository: element-hq/element-web
- name: 📥 Download artifact
uses: actions/download-artifact@v4
with:
name: webapp
path: webapp
- uses: actions/setup-node@v4
with:
cache: "yarn"
cache-dependency-path: element-web/yarn.lock
node-version: "lts/*"
- name: Install dependencies
working-directory: element-web
run: yarn install --frozen-lockfile
- name: Get installed Playwright version
id: playwright
working-directory: element-web
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@v4
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: element-web
run: yarn playwright install --with-deps
- name: Run Playwright tests
run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }}
working-directory: element-web
- name: Upload blob report to GitHub Actions Artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: all-blob-reports-${{ matrix.runner }}
path: element-web/blob-report
retention-days: 1
complete:
name: end-to-end-tests
needs: playwright
if: always()
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
if: inputs.skip != true
with:
persist-credentials: false
repository: element-hq/element-web
- uses: actions/setup-node@v4
if: inputs.skip != true
with:
cache: "yarn"
node-version: "lts/*"
- name: Install dependencies
if: inputs.skip != true
run: yarn install --frozen-lockfile
- name: Download blob reports from GitHub Actions Artifacts
if: inputs.skip != true
uses: actions/download-artifact@v4
with:
pattern: all-blob-reports-*
path: all-blob-reports
merge-multiple: true
- name: Merge into HTML Report
if: inputs.skip != true
run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports
env:
# Only pass creds to the flaky-reporter on main branch runs
GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }}
# Upload the HTML report even if one of our reporters fails, this can happen when stale screenshots are detected
- name: Upload HTML report
if: always() && inputs.skip != true
uses: actions/upload-artifact@v4
with:
name: html-report
path: playwright-report
retention-days: 14
- if: needs.playwright.result != 'skipped' && needs.playwright.result != 'success'
run: exit 1

48
.github/workflows/netlify.yaml vendored Normal file
View file

@ -0,0 +1,48 @@
# Triggers after the layered build has finished, taking the artifact
# and uploading it to netlify
name: Upload Preview Build to Netlify
on:
workflow_run:
workflows: ["End to End Tests"]
types:
- completed
jobs:
deploy:
if: github.event.workflow_run.conclusion != 'cancelled' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-22.04
environment: Netlify
steps:
- name: 📝 Create Deployment
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
id: deployment
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: Netlify
ref: ${{ github.event.workflow_run.head_sha }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
Exercise caution. Use test accounts.
- name: 📥 Download artifact
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: webapp
path: webapp
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v3
with:
path: webapp
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ secrets.NETLIFY_SITE_ID }}
deployment_env: ${{ steps.deployment.outputs.env }}
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
Exercise caution. Use test accounts.

View file

@ -64,7 +64,6 @@ jobs:
const repos = [
"element-hq/element-desktop",
"element-hq/element-web",
"element-hq/matrix-react-sdk",
"matrix-org/matrix-js-sdk",
];
const teams = [

View file

@ -0,0 +1,45 @@
name: Update Playwright docker images
on:
workflow_dispatch: {}
schedule:
- cron: "0 6 * * *" # Every day at 6am UTC
jobs:
update:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Update synapse image
run: |
docker pull "$IMAGE"
INSPECT=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE")
DIGEST=${INSPECT#*@}
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@5e914681df9dc83aa4e4905692ca88beb2f9e91f # v7
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/playwright-image-updates
delete-branch: true
title: Playwright Docker image updates
labels: |
T-Task
- name: Enable automerge
run: gh pr merge --merge --auto "$PR_NUMBER"
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
- name: Enable autoapprove
run: |
gh pr review --approve "$PR_NUMBER"
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}

View file

@ -0,0 +1,16 @@
name: Pull Request Base Branch
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
check_base_branch:
name: Check PR base branch
runs-on: ubuntu-22.04
steps:
- uses: actions/github-script@v7
with:
script: |
const baseBranch = context.payload.pull_request.base.ref;
if (!['develop', 'staging'].includes(baseBranch) && !baseBranch.startsWith('feat/')) {
core.setFailed(`Invalid base branch: ${baseBranch}`);
}

View file

@ -7,5 +7,3 @@ concurrency: ${{ github.workflow }}
jobs:
draft:
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop
with:
include-changes: matrix-react-sdk

View file

@ -11,5 +11,4 @@ jobs:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
with:
dependencies: |
matrix-react-sdk
matrix-js-sdk

View file

@ -12,11 +12,6 @@ on:
required: true
type: boolean
default: true
matrix-react-sdk:
description: Prepare matrix-react-sdk
required: true
type: boolean
default: true
matrix-js-sdk:
description: Prepare matrix-js-sdk
required: true
@ -25,9 +20,6 @@ on:
jobs:
prepare:
runs-on: ubuntu-latest
env:
# The order is specified bottom-up to avoid any races for allchange
REPOS: matrix-js-sdk matrix-react-sdk element-web element-desktop
steps:
- name: Checkout Element Desktop
uses: actions/checkout@v4
@ -49,16 +41,6 @@ jobs:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Checkout Matrix React SDK
uses: actions/checkout@v4
if: inputs.matrix-react-sdk
with:
repository: element-hq/matrix-react-sdk
path: matrix-react-sdk
ref: staging
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Checkout Matrix JS SDK
uses: actions/checkout@v4
if: inputs.matrix-js-sdk
@ -83,10 +65,6 @@ jobs:
if: inputs.element-web
run: |
git -C "element-web" merge origin/develop
- name: Merge React SDK
if: inputs.matrix-react-sdk
run: |
git -C "matrix-react-sdk" merge origin/develop
- name: Merge JS SDK
if: inputs.matrix-js-sdk
run: |
@ -106,17 +84,6 @@ jobs:
check-name: draft
allowed-conclusions: success
- name: Wait for matrix-react-sdk draft
if: inputs.matrix-react-sdk
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork
with:
ref: staging
repo: element-hq/matrix-react-sdk
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
wait-interval: 10
check-name: draft
allowed-conclusions: success
- name: Wait for element-web draft
if: inputs.element-web
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork

View file

@ -10,7 +10,10 @@ concurrency:
jobs:
sonarqube:
name: 🩻 SonarQube
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
with:
sharded: true

View file

@ -6,11 +6,16 @@ on:
merge_group:
types: [checks_requested]
repository_dispatch:
types: [element-web-notify]
types: [upstream-sdk-notify]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
env:
# These must be set for fetchdep.sh to get the right branch
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
jobs:
ts_lint:
name: "Typescript Syntax Check"
@ -29,11 +34,47 @@ jobs:
- name: Typecheck
run: "yarn run lint:types"
- name: Switch js-sdk to release mode
working-directory: node_modules/matrix-js-sdk
run: |
scripts/switch_package_to_release.cjs
yarn install
yarn run build:compile
yarn run build:types
- name: Typecheck (release mode)
run: "yarn run lint:types"
# Temporary while we directly import matrix-js-sdk/src/* which means we need
# certain @types/* packages to make sense of matrix-js-sdk types.
#- name: Typecheck (release mode; no yarn link)
# if: github.event_name != 'pull_request' && github.ref_name != 'master'
# run: |
# yarn unlink matrix-js-sdk
# yarn add github:matrix-org/matrix-js-sdk#develop
# yarn install --force
# yarn run lint:types
i18n_lint:
name: "i18n Check"
uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main
with:
hardcoded-words: "Element"
allowed-hardcoded-keys: |
console_dev_note
labs|element_call_video_rooms
labs|feature_disable_call_per_sender_encryption
voip|element_call
rethemendex_lint:
name: "Rethemendex Check"
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- run: ./res/css/rethemendex.sh
- run: git diff --exit-code
js_lint:
name: "ESLint"
@ -64,9 +105,9 @@ jobs:
cache: "yarn"
node-version: "lts/*"
# Needs branch matching as it inherits .stylelintrc.js from matrix-react-sdk
- name: Install Dependencies
run: "./scripts/layered.sh"
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install"
- name: Run Linter
run: "yarn run lint:style"

110
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,110 @@
name: Tests
on:
pull_request: {}
merge_group:
types: [checks_requested]
push:
branches: [develop, master]
repository_dispatch:
types: [upstream-sdk-notify]
workflow_call:
inputs:
disable_coverage:
type: boolean
required: false
description: "Specify true to skip generating and uploading coverage for tests"
matrix-js-sdk-sha:
type: string
required: false
description: "The matrix-js-sdk SHA to use"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
env:
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' && inputs.disable_coverage != 'true' }}
# fetchdep.sh needs to know our PR number
PR_NUMBER: ${{ github.event.pull_request.number }}
jobs:
jest:
name: Jest
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
# Run multiple instances in parallel to speed up the tests
runner: [1, 2]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-web' || github.repository }}
- name: Yarn cache
uses: actions/setup-node@v4
with:
node-version: "lts/*"
cache: "yarn"
- name: Install Deps
run: "./scripts/ci/install-deps.sh"
env:
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Jest Cache
uses: actions/cache@v4
with:
path: /tmp/jest_cache
key: ${{ hashFiles('**/yarn.lock') }}
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2
- name: Run tests
run: |
yarn test \
--coverage=${{ env.ENABLE_COVERAGE }} \
--ci \
--max-workers ${{ steps.cpu-cores.outputs.count }} \
--shard ${{ matrix.runner }}/${{ strategy.job-total }} \
--cacheDirectory /tmp/jest_cache
env:
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
# tell jest to use coloured output
FORCE_COLOR: true
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.runner }}.lcov.info
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.runner }}
path: |
coverage
!coverage/lcov-report
complete:
name: jest-tests
needs: jest
if: always()
runs-on: ubuntu-22.04
steps:
- if: needs.jest.result != 'skipped' && needs.jest.result != 'success'
run: exit 1
- name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
description: SonarCloud skipped
context: SonarCloud Code Analysis
sha: ${{ github.sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}

2
.gitignore vendored
View file

@ -29,3 +29,5 @@ electron/pub
/build_config.yaml
/book
/index.html
# version file and tarball created by `npm pack` / `yarn pack`
/git-revision.txt

1
.node-version Normal file
View file

@ -0,0 +1 @@
20

View file

@ -25,10 +25,17 @@ src/vector/modernizr.js
/docs/lib
/book
/debian/tmp
/.npmrc
package-lock.json
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
/CHANGELOG.md
/docs/changelogs
# Legacy skinning file that some people might still have
/src/component-index.js
# Downloaded and already minified
res/jitsi_external_api.min.js
# This file is also machine-generated
/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json

1
.prettierrc.cjs Normal file
View file

@ -0,0 +1 @@
module.exports = require("eslint-plugin-matrix-org/.prettierrc.js");

View file

@ -1,4 +1,50 @@
module.exports = {
...require("matrix-react-sdk/.stylelintrc.js"),
extends: ["stylelint-config-standard"],
customSyntax: require("postcss-scss"),
plugins: ["stylelint-scss"],
rules: {
"comment-empty-line-before": null,
"declaration-empty-line-before": null,
"length-zero-no-unit": null,
"rule-empty-line-before": null,
"color-hex-length": null,
"at-rule-no-unknown": null,
"no-descending-specificity": null,
"scss/at-rule-no-unknown": [
true,
{
// https://github.com/vector-im/element-web/issues/10544
ignoreAtRules: ["define-mixin"],
},
],
// Disable `&_kind`-style selectors while our unused CSS approach is "Find & Replace All"
// rather than a CI thing. Shorthand selectors are harder to detect when searching for a
// class name. This regex is trying to *allow* anything except `&words`, such as `&::before`,
// `&.mx_Class`, etc.
"selector-nested-pattern": "^((&[ :.\\[,])|([^&]))",
// Disable some defaults
"selector-class-pattern": null,
"custom-property-pattern": null,
"selector-id-pattern": null,
"keyframes-name-pattern": null,
"alpha-value-notation": null,
"color-function-notation": null,
"selector-not-notation": null,
"import-notation": null,
"value-keyword-case": null,
"declaration-block-no-redundant-longhand-properties": null,
"declaration-block-no-duplicate-properties": [
true,
// useful for fallbacks
{ ignore: ["consecutive-duplicates-with-different-values"] },
],
"shorthand-property-no-redundant-values": null,
"property-no-vendor-prefix": null,
"value-no-vendor-prefix": null,
"selector-no-vendor-prefix": null,
"media-feature-name-no-vendor-prefix": null,
"number-max-precision": null,
"no-invalid-double-slash-comments": true,
"media-feature-range-notation": null,
},
};

View file

@ -79,7 +79,6 @@ element-web notes: Fix a bug where the 'Herd' button only worked on Tuesdays
This example is for Element Web. You can specify:
- matrix-react-sdk
- element-web
- element-desktop
@ -113,14 +112,12 @@ checks, so please check back after a few minutes.
Your PR should include tests.
For new user facing features in `matrix-js-sdk`, `matrix-react-sdk` or `element-web`, you
must include:
For new user facing features in `matrix-js-sdk` or `element-web`, you must include:
1. Comprehensive unit tests written in Jest. These are located in `/test`.
2. "happy path" end-to-end tests.
These are located in `/playwright/e2e` in `matrix-react-sdk`, and
are run using `element-web`. Ideally, you would also include tests for edge
and error cases.
These are located in `/playwright/e2e`, and are run using `element-web`.
Ideally, you would also include tests for edge and error cases.
Unit tests are expected even when the feature is in labs. It's good practice
to write tests alongside the code as it ensures the code is testable from
@ -134,8 +131,7 @@ end-to-end test; which is best depends on what sort of test most concisely
exercises the area.
Changes to must be accompanied by unit tests written in Jest.
These are located in `/spec/` in `matrix-js-sdk` or `/test/` in `element-web`
and `matrix-react-sdk`.
These are located in `/spec/` in `matrix-js-sdk` or `/test/` in `element-web`.
When writing unit tests, please aim for a high level of test coverage
for new code - 80% or greater. If you cannot achieve that, please document

View file

@ -1,11 +1,8 @@
# Builder
FROM --platform=$BUILDPLATFORM node:20-bullseye as builder
# Support custom branches of the react-sdk and js-sdk. This also helps us build
# images of element-web develop.
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
ARG USE_CUSTOM_SDKS=false
ARG REACT_SDK_REPO="https://github.com/matrix-org/matrix-react-sdk.git"
ARG REACT_SDK_BRANCH="master"
ARG JS_SDK_REPO="https://github.com/matrix-org/matrix-js-sdk.git"
ARG JS_SDK_BRANCH="master"

View file

@ -10,7 +10,7 @@
# Element
Element (formerly known as Vector and Riot) is a Matrix web client built using the [Matrix
React SDK](https://github.com/matrix-org/matrix-react-sdk).
JS SDK](https://github.com/matrix-org/matrix-js-sdk).
# Supported Environments
@ -208,10 +208,9 @@ into Element itself.
# Setting up a dev environment
Much of the functionality in Element is actually in the `matrix-react-sdk` and
`matrix-js-sdk` modules. It is possible to set these up in a way that makes it
easy to track the `develop` branches in git and to make local changes without
having to manually rebuild each time.
Much of the functionality in Element is actually in the `matrix-js-sdk` module.
It is possible to set these up in a way that makes it easy to track the `develop` branches
in git and to make local changes without having to manually rebuild each time.
First clone and build `matrix-js-sdk`:
@ -223,17 +222,6 @@ yarn install
popd
```
Then similarly with `matrix-react-sdk`:
```bash
git clone https://github.com/matrix-org/matrix-react-sdk.git
pushd matrix-react-sdk
yarn link
yarn link matrix-js-sdk
yarn install
popd
```
Clone the repo and switch to the `element-web` directory:
```bash
@ -248,7 +236,6 @@ Finally, build and start Element itself:
```bash
yarn link matrix-js-sdk
yarn link matrix-react-sdk
yarn install
yarn start
```
@ -294,8 +281,7 @@ sudo sysctl -p
---
When you make changes to `matrix-react-sdk` or `matrix-js-sdk` they should be
automatically picked up by webpack and built.
When you make changes to `matrix-js-sdk` they should be automatically picked up by webpack and built.
If any of these steps error with, `file table overflow`, you are probably on a mac
which has a very low limit on max open files. Run `ulimit -Sn 1024` and try again.

6
__mocks__/FontManager.js Normal file
View file

@ -0,0 +1,6 @@
// Stub out FontManager for tests as it doesn't validate anything we don't already know given
// our fixed test environment and it requires the installation of node-canvas.
module.exports = {
fixupColorFonts: () => Promise.resolve(),
};

2
__mocks__/empty.js Normal file
View file

@ -0,0 +1,2 @@
// Yes, this is empty.
module.exports = {};

1
__mocks__/imageMock.js Normal file
View file

@ -0,0 +1 @@
module.exports = "image-file-stub";

4
__mocks__/languages.json Normal file
View file

@ -0,0 +1,4 @@
{
"en": "en_EN.json",
"en-us": "en_US.json"
}

40
__mocks__/maplibre-gl.js Normal file
View file

@ -0,0 +1,40 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
const EventEmitter = require("events");
const { LngLat, NavigationControl, LngLatBounds } = require("maplibre-gl");
class MockMap extends EventEmitter {
addControl = jest.fn();
removeControl = jest.fn();
zoomIn = jest.fn();
zoomOut = jest.fn();
setCenter = jest.fn();
setStyle = jest.fn();
fitBounds = jest.fn();
}
const MockMapInstance = new MockMap();
class MockAttributionControl {}
class MockGeolocateControl extends EventEmitter {
trigger = jest.fn();
}
const MockGeolocateInstance = new MockGeolocateControl();
const MockMarker = {};
MockMarker.setLngLat = jest.fn().mockReturnValue(MockMarker);
MockMarker.addTo = jest.fn().mockReturnValue(MockMarker);
MockMarker.remove = jest.fn().mockReturnValue(MockMarker);
module.exports = {
Map: jest.fn().mockReturnValue(MockMapInstance),
GeolocateControl: jest.fn().mockReturnValue(MockGeolocateInstance),
Marker: jest.fn().mockReturnValue(MockMarker),
LngLat,
LngLatBounds,
NavigationControl,
AttributionControl: MockAttributionControl,
};

2
__mocks__/svg.js Normal file
View file

@ -0,0 +1,2 @@
export const Icon = "div";
export default "image-file-stub";

View file

@ -0,0 +1,11 @@
/*
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.
*/
export default function workerFactory(options) {
return jest.fn;
}

View file

@ -5,7 +5,6 @@ adjacent to. As of writing, these are:
- element-desktop
- element-web
- matrix-react-sdk
- matrix-js-sdk
Other projects might extend this code style for increased strictness. For example, matrix-events-sdk

View file

@ -34,8 +34,22 @@
- [App load order](app-load.md)
- [Translation](translating-dev.md)
- [Theming](theming.md)
- [Playwright end to end tests](playwright.md)
- [Memory profiling](memory-profiles-and-leaks.md)
- [Jitsi](jitsi-dev.md)
- [Feature flags](feature-flags.md)
- [OIDC and delegated authentication](oidc.md)
- [Release Process](release.md)
# Deep dive
- [Skinning](skinning.md)
- [Cider editor](ciderEditor.md)
- [Iconography](icons.md)
- [Jitsi](jitsi.md)
- [Local echo](local-echo-dev.md)
- [Media](media-handling.md)
- [Room List Store](room-list-store.md)
- [Scrolling](scrolling.md)
- [Usercontent](usercontent.md)
- [Widget layouts](widget-layouts.md)

View file

@ -19,8 +19,7 @@ If you're looking for inspiration on where to start, keep reading!
All the issues for Element Web live in the
[element-web](https://github.com/element-hq/element-web) repository, including
issues that actually need fixing in `matrix-react-sdk` or one of the related
repos.
issues that actually need fixing in one of the related repos.
The first place to look is for
[issues tagged with "good first issue"](https://github.com/element-hq/element-web/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22).

71
docs/ciderEditor.md Normal file
View file

@ -0,0 +1,71 @@
# The CIDER (Contenteditable-Input-Diff-Error-Reconcile) editor
The CIDER editor is a custom editor written for Element.
Most of the code can be found in the `/editor/` directory.
It is used to power the composer main composer (both to send and edit messages), and might be used for other usecases where autocomplete is desired (invite box, ...).
## High-level overview.
The editor is backed by a model that contains parts.
A part has some text and a type (plain text, pill, ...). When typing in the editor,
the model validates the input and updates the parts.
The parts are then reconciled with the DOM.
## Inner workings
When typing in the `contenteditable` element, the `input` event fires and
the DOM of the editor is turned into a string. The way this is done has
some logic to it to deal with adding newlines for block elements, to make sure
the caret offset is calculated in the same way as the content string, and to ignore
caret nodes (more on that later).
For these reasons it doesn't use `innerText`, `textContent` or anything similar.
The model addresses any content in the editor within as an offset within this string.
The caret position is thus also converted from a position in the DOM tree
to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.ts`.
Once the content string and caret offset is calculated, it is passed to the `update()`
method of the model. The model first calculates the same content string of its current parts,
basically just concatenating their text. It then looks for differences between
the current and the new content string. The diffing algorithm is very basic,
and assumes there is only one change around the caret offset,
so this should be very inexpensive. See `diff.ts` for details.
The result of the diffing is the strings that were added and/or removed from
the current content. These differences are then applied to the parts,
where parts can apply validation logic to these changes.
For example, if you type an @ in some plain text, the plain text part rejects
that character, and this character is then presented to the part creator,
which will turn it into a pill candidate part.
Pill candidate parts are what opens the auto completion, and upon picking a completion,
replace themselves with an actual pill which can't be edited anymore.
The diffing is needed to preserve state in the parts apart from their text
(which is the only thing the model receives from the DOM), e.g. to build
the model incrementally. Any text that didn't change is assumed
to leave the parts it intersects alone.
The benefit of this is that we can use the `input` event, which is broadly supported,
to find changes in the editor. We don't have to rely on keyboard events,
which relate poorly to text input or changes, and don't need the `beforeinput` event,
which isn't broadly supported yet.
Once the parts of the model are updated, the DOM of the editor is then reconciled
with the new model state, see `renderModel` in `render.ts` for this.
If the model didn't reject the input and didn't make any additional changes,
this won't make any changes to the DOM at all, and should thus be fairly efficient.
For the browser to allow the user to place the caret between two pills,
or between a pill and the start and end of the line, we need some extra DOM nodes.
These DOM nodes are called caret nodes, and contain an invisble character, so
the caret can be placed into them. The model is unaware of caret nodes, and they
are only added to the DOM during the render phase. Likewise, when calculating
the content string, caret nodes need to be ignored, as they would confuse the model.
As part of the reconciliation, the caret position is also adjusted to any changes
the model made to the input. The caret is passed around in two formats.
The model receives the caret _offset_ within the content string (which includes
an atNodeEnd flag to make it unambiguous if it is at a part and or the next part start).
The model converts this to a caret _position_ internally, which has a partIndex
and an offset within the part text, which is more natural to work with.
From there on, the caret _position_ is used, also during reconciliation.

View file

@ -109,7 +109,7 @@ instance. As of writing those settings are not fully documented, however a few a
}
```
These values will take priority over the hardcoded defaults for the settings. For a list of available settings, see
[Settings.tsx](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/settings/Settings.tsx).
[Settings.tsx](https://github.com/element-hq/element-web/blob/develop/src/settings/Settings.tsx).
## Customisation & branding

View file

@ -12,7 +12,7 @@ Element Web and the React SDK support "customisation points" that can be used to
easily add custom logic specific to a particular deployment of Element Web.
An example of this is the [security customisations
module](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/customisations/Security.ts).
module](https://github.com/element-hq/element-web/blob/develop/src/customisations/Security.ts).
This module in the React SDK only defines some empty functions and their types:
it does not do anything by default.
@ -54,7 +54,7 @@ UI for some actions can be hidden via the ComponentVisibility customisation:
- creating rooms,
- creating spaces,
To customise visibility create a customisation module from [ComponentVisibility](https://github.com/matrix-org/matrix-react-sdk/blob/master/src/customisations/ComponentVisibility.ts) following the instructions above.
To customise visibility create a customisation module from [ComponentVisibility](https://github.com/element-hq/element-web/blob/master/src/customisations/ComponentVisibility.ts) following the instructions above.
`shouldShowComponent` determines whether the active MatrixClient user should be able to use
the given UI component. When `shouldShowComponent` returns falsy all UI components for that feature will be hidden.

View file

@ -35,7 +35,7 @@ clients commit to doing the associated clean up work once a feature stabilises.
When starting work on a feature, we should create a matching feature flag:
1. Add a new
[setting](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/settings/Settings.tsx)
[setting](https://github.com/element-hq/element-web/blob/develop/src/settings/Settings.tsx)
of the form:
```js
@ -93,14 +93,14 @@ Once we're confident that a feature is working well, we should remove or convert
If the feature is meant to be turned off/on by the user:
1. Remove `isFeature` from the [setting](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/settings/Settings.ts)
1. Remove `isFeature` from the [setting](https://github.com/element-hq/element-web/blob/develop/src/settings/Settings.ts)
2. Change the `default` to `true` (if desired).
3. Remove the feature from the [labs documentation](https://github.com/element-hq/element-web/blob/develop/docs/labs.md)
4. Celebrate! 🥳
If the feature is meant to be forced on (non-configurable):
1. Remove the [setting](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/settings/Settings.ts)
1. Remove the [setting](https://github.com/element-hq/element-web/blob/develop/src/settings/Settings.ts)
2. Remove all `getValue` lines that test for the feature.
3. Remove the feature from the [labs documentation](https://github.com/element-hq/element-web/blob/develop/docs/labs.md)
4. If applicable, remove the feature state from

6
docs/features/README.md Normal file
View file

@ -0,0 +1,6 @@
# Feature documention
The idea of this folder is to document the features we support in different parts of the app.
In case anyone needs to work on a given part, and isn't aware of all the features in the area,
they will hopefully get an idea for all the supported functionality to know what to take into account
when making changes.

38
docs/features/composer.md Normal file
View file

@ -0,0 +1,38 @@
# Composer Features
## Auto Complete
- Hitting tab tries to auto-complete the word before the caret as a room member
- If no matching name is found, a visual bell is shown
- @ + a letter opens auto complete for members starting with the given letter
- When inserting a user pill at the start in the composer, a colon and space is appended to the pill
- When inserting a user pill anywhere else in composer, only a space is appended to the pill
- # + a letter opens auto complete for rooms starting with the given letter
- : open auto complete for emoji
- Pressing arrow-up/arrow-down while the autocomplete is open navigates between auto complete options
- Pressing tab while the autocomplete is open goes to the next autocomplete option,
wrapping around at the end after reverting to the typed text first.
## Formatting
- When selecting text, a formatting bar appears above the selection.
- The formatting bar allows to format the selected test as:
bold, italic, strikethrough, a block quote, and a code block (inline if no linebreak is selected).
- Formatting is applied as markdown syntax.
- Hitting ctrl/cmd+B also marks the selected text as bold
- Hitting ctrl/cmd+I also marks the selected text as italic
- Hitting ctrl/cmd+> also marks the selected text as a blockquote
## Misc
- When hitting the arrow-up button while having the caret at the start in the composer,
the last message sent by the syncing user is edited.
- Clicking a display name on an event in the timeline inserts a user pill into the composer
- Emoticons (like :-), >:-), :-/, ...) are replaced by emojis while typing if the relevant setting is enabled
- Typing in the composer sends typing notifications in the room
- Pressing ctrl/mod+z and ctrl/mod+y undoes/redoes modifications
- Pressing shift+enter inserts a line break
- Pressing enter sends the message.
- Choosing "Quote" in the context menu of an event inserts a quote of the event body in the composer.
- Choosing "Reply" in the context menu of an event shows a preview above the composer to reply to.
- Pressing alt+arrow up/arrow down navigates in previously sent messages, putting them in the composer.

View file

@ -0,0 +1,59 @@
# Keyboard shortcuts
## Using the `KeyBindingManager`
The `KeyBindingManager` (accessible using `getKeyBindingManager()`) is a class
with several methods that allow you to get a `KeyBindingAction` based on a
`KeyboardEvent | React.KeyboardEvent`.
The event passed to the `KeyBindingManager` gets compared to the list of
shortcuts that are retrieved from the `IKeyBindingsProvider`s. The
`IKeyBindingsProvider` is in `KeyBindingDefaults`.
### Examples
Let's say we want to close a menu when the correct keys were pressed:
```ts
const onKeyDown = (ev: KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Escape:
closeMenu();
break;
default:
handled = false;
break;
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
};
```
## Managing keyboard shortcuts
There are a few things at play when it comes to keyboard shortcuts. The
`KeyBindingManager` gets `IKeyBindingsProvider`s one of which is
`defaultBindingsProvider` defined in `KeyBindingDefaults`. In
`KeyBindingDefaults` a `getBindingsByCategory()` method is used to create
`KeyBinding`s based on `KeyboardShortcutSetting`s defined in
`KeyboardShortcuts`.
### Adding keyboard shortcuts
To add a keyboard shortcut there are two files we have to look at:
`KeyboardShortcuts.ts` and `KeyBindingDefaults.ts`. In most cases we only need
to edit `KeyboardShortcuts.ts`: add a `KeyBindingAction` and add the
`KeyBindingAction` to the `KEYBOARD_SHORTCUTS` object.
Though, to make matters worse, sometimes we want to add a shortcut that has
multiple keybindings associated with. This keyboard shortcut won't be
customizable as it would be rather difficult to manage both from the point of
the settings and the UI. To do this, we have to add a `KeyBindingAction` and add
the UI representation of that keyboard shortcut to the `getUIOnlyShortcuts()`
method. Then, we also need to add the keybinding to the correct method in
`KeyBindingDefaults`.

56
docs/icons.md Normal file
View file

@ -0,0 +1,56 @@
# Icons
Icons are loaded using [@svgr/webpack](https://www.npmjs.com/package/@svgr/webpack).
This is configured in [element-web](https://github.com/vector-im/element-web/blob/develop/webpack.config.js#L458).
Each `.svg` exports a `ReactComponent` at the named export `Icon`.
Icons have `role="presentation"` and `aria-hidden` automatically applied. These can be overriden by passing props to the icon component.
SVG file recommendations:
- Colours should not be defined absolutely. Use `currentColor` instead.
- SVG files should be taken from the design compound as they are. Some icons contain special padding.
This means that there should be icons for each size, e.g. warning-16px and warning-32px.
Example usage:
```
import { Icon as FavoriteIcon } from 'res/img/element-icons/favorite.svg';
const MyComponent = () => {
return <>
<FavoriteIcon className="mx_Icon mx_Icon_16">
</>;
}
```
If possible, use the icon classes from [here](../res/css/compound/_Icon.pcss).
## Custom styling
Icon components are svg elements and may be custom styled as usual.
`_MyComponents.pcss`:
```css
.mx_MyComponent-icon {
height: 20px;
width: 20px;
* {
fill: $accent;
}
}
```
`MyComponent.tsx`:
```typescript
import { Icon as FavoriteIcon } from 'res/img/element-icons/favorite.svg';
const MyComponent = () => {
return <>
<FavoriteIcon className="mx_MyComponent-icon" role="img" aria-hidden="false">
</>;
}
```

BIN
docs/img/RoomListStore2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View file

@ -67,8 +67,6 @@ element-web branch and then run:
```bash
docker build -t \
--build-arg USE_CUSTOM_SDKS=true \
--build-arg REACT_SDK_REPO="https://github.com/matrix-org/matrix-react-sdk.git" \
--build-arg REACT_SDK_BRANCH="develop" \
--build-arg JS_SDK_REPO="https://github.com/matrix-org/matrix-js-sdk.git" \
--build-arg JS_SDK_BRANCH="develop" \
.

View file

@ -70,3 +70,41 @@ The domain used is the one specified by the `/.well-known/matrix/client` endpoin
For active Jitsi widgets in the room, a native Jitsi widget UI is created and points to the instance specified in the `domain` key of the widget content data.
Element Android manages allowed native widgets permissions a bit differently than web widgets (as the data shared are different and never shared with the widget URL). For Jitsi widgets, permissions are requested only once per domain (consent saved in account data).
# Jitsi Wrapper
**Note**: These are developer docs. Please consult your client's documentation for
instructions on setting up Jitsi.
The react-sdk wraps all Jitsi call widgets in a local wrapper called `jitsi.html`
which takes several parameters:
_Query string_:
- `widgetId`: The ID of the widget. This is needed for communication back to the
react-sdk.
- `parentUrl`: The URL of the parent window. This is also needed for
communication back to the react-sdk.
_Hash/fragment (formatted as a query string)_:
- `conferenceDomain`: The domain to connect Jitsi Meet to.
- `conferenceId`: The room or conference ID to connect Jitsi Meet to.
- `isAudioOnly`: Boolean for whether this is a voice-only conference. May not
be present, should default to `false`.
- `startWithAudioMuted`: Boolean for whether the calls start with audio
muted. May not be present.
- `startWithVideoMuted`: Boolean for whether the calls start with video
muted. May not be present.
- `displayName`: The display name of the user viewing the widget. May not
be present or could be null.
- `avatarUrl`: The HTTP(S) URL for the avatar of the user viewing the widget. May
not be present or could be null.
- `userId`: The MXID of the user viewing the widget. May not be present or could
be null.
The react-sdk will assume that `jitsi.html` is at the path of wherever it is currently
being served. For example, `https://develop.element.io/jitsi.html` or `vector://webapp/jitsi.html`.
The `jitsi.html` wrapper can use the react-sdk's `WidgetApi` to communicate, making
it easier to actually implement the feature.

38
docs/local-echo-dev.md Normal file
View file

@ -0,0 +1,38 @@
# Local echo (developer docs)
The React SDK provides some local echo functionality to allow for components to do something
quickly and fall back when it fails. This is all available in the `local-echo` directory within
`stores`.
Echo is handled in EchoChambers, with `GenericEchoChamber` being the base implementation for all
chambers. The `EchoChamber` class is provided as semantic access to a `GenericEchoChamber`
implementation, such as the `RoomEchoChamber` (which handles echoable details of a room).
Anything that can be locally echoed will be provided by the `GenericEchoChamber` implementation.
The echo chamber will also need to deal with external changes, and has full control over whether
or not something has successfully been echoed.
An `EchoContext` is provided to echo chambers (usually with a matching type: `RoomEchoContext`
gets provided to a `RoomEchoChamber` for example) with details about their intended area of
effect, as well as manage `EchoTransaction`s. An `EchoTransaction` is simply a unit of work that
needs to be locally echoed.
The `EchoStore` manages echo chamber instances, builds contexts, and is generally less semantically
accessible than the `EchoChamber` class. For separation of concerns, and to try and keep things
tidy, this is an intentional design decision.
**Note**: The local echo stack uses a "whenable" pattern, which is similar to thenables and
`EventEmitter`. Whenables are ways of actioning a changing condition without having to deal
with listeners being torn down. Once the reference count of the Whenable causes garbage collection,
the Whenable's listeners will also be torn down. This is accelerated by the `IDestroyable` interface
usage.
## Audit functionality
The UI supports a "Server isn't responding" dialog which includes a partial audit log-like
structure to it. This is partially the reason for added complexity of `EchoTransaction`s
and `EchoContext`s - this information feeds the UI states which then provide direct retry
mechanisms.
The `EchoStore` is responsible for ensuring that the appropriate non-urgent toast (lower left)
is set up, where the dialog then drives through the contexts and transactions.

19
docs/media-handling.md Normal file
View file

@ -0,0 +1,19 @@
# Media handling
Surely media should be as easy as just putting a URL into an `img` and calling it good, right?
Not quite. Matrix uses something called a Matrix Content URI (better known as MXC URI) to identify
content, which is then converted to a regular HTTPS URL on the homeserver. However, sometimes that
URL can change depending on deployment considerations.
The react-sdk features a [customisation endpoint](https://github.com/vector-im/element-web/blob/develop/docs/customisations.md)
for media handling where all conversions from MXC URI to HTTPS URL happen. This is to ensure that
those obscure deployments can route all their media to the right place.
For development, there are currently two functions available: `mediaFromMxc` and `mediaFromContent`.
The `mediaFromMxc` function should be self-explanatory. `mediaFromContent` takes an event content as
a parameter and will automatically parse out the source media and thumbnail. Both functions return
a `Media` object with a number of options on it, such as getting various common HTTPS URLs for the
media.
**It is extremely important that all media calls are put through this customisation endpoint.** So
much so it's a lint rule to avoid accidental use of the wrong functions.

View file

@ -23,11 +23,11 @@ the current directory as the build context (the `.` in `docker build -t my-eleme
## Writing modules
While writing modules is meant to be easy, not everything is possible yet. For modules which want to do something we haven't
exposed in the module API, the module API will need to be updated. This means a PR to both the
[`matrix-react-sdk`](https://github.com/matrix-org/matrix-react-sdk) and [`matrix-react-sdk-module-api`](https://github.com/matrix-org/matrix-react-sdk-module-api).
exposed in the module API, the module API will need to be updated. This means a PR to both this repo
and [`matrix-react-sdk-module-api`](https://github.com/matrix-org/matrix-react-sdk-module-api).
Once your change to the module API is accepted, the `@matrix-org/react-sdk-module-api` dependency gets updated at the
`matrix-react-sdk` and `element-web` layers (usually by us, the maintainers) to ensure your module can operate.
`element-web` layer (usually by us, the maintainers) to ensure your module can operate.
If you're not adding anything to the module API, or your change was accepted per above, then start off with a clone of
our [ILAG module](https://github.com/element-hq/element-web-ilag-module) which will give you a general idea for what the

219
docs/playwright.md Normal file
View file

@ -0,0 +1,219 @@
# Playwright in Element Web
## Contents
- How to run the tests
- How the tests work
- How to write great Playwright tests
- Visual testing
## Running the Tests
Our Playwright tests run automatically as part of our CI along with our other tests,
on every pull request and on every merge to develop & master.
You may need to follow instructions to set up your development environment for running
Playwright by following <https://playwright.dev/docs/browsers#install-browsers> and
<https://playwright.dev/docs/browsers#install-system-dependencies>.
However the Playwright tests are run, an element-web instance must be running on
http://localhost:8080 (this is configured in `playwright.config.ts`) - this is what will
be tested. When running Playwright tests yourself, the standard `yarn start` from the
element-web project is fine: leave it running it a different terminal as you would
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 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
yarn run test:playwright --spec playwright/e2e/register/register.spec.ts
```
Playwright also has its own UI that you can use to run and debug the tests.
To launch it:
```shell
yarn run test:playwright:open --headed --debug
```
See more command line options at <https://playwright.dev/docs/test-cli>.
### Running with Rust cryptography
`matrix-js-sdk` is currently in the
[process](https://github.com/vector-im/element-web/issues/21972) of being
updated to replace its end-to-end encryption implementation to use the [Matrix
Rust SDK](https://github.com/matrix-org/matrix-rust-sdk). This is not currently
enabled by default, but it is possible to have Playwright configure Element to use
the Rust crypto implementation by passing `--project="Rust Crypto"` or using
the top left options in open mode.
## How the Tests Work
Everything Playwright-related lives in the `playwright/` subdirectory of react-sdk
as is typical for Playwright tests. Likewise, tests live in `playwright/e2e`.
`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. `playwright/plugins/homeserver/synapse/templates`
contains template configuration files for each different configuration.
Each test suite can then launch whatever Synapse instances it needs in whatever
configurations.
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
Mostly this is the same advice as for writing any other Playwright test: the Playwright
docs are well worth a read if you're not already familiar with Playwright testing, eg.
https://playwright.dev/docs/best-practices. To avoid your tests being flaky it is also
recommended to use [auto-retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions).
### Getting a Synapse
We heavily leverage the magic of [Playwright fixtures](https://playwright.dev/docs/test-fixtures).
To acquire a homeserver within a test just add the `homeserver` fixture to the test:
```typescript
test("should do something", async ({ homeserver }) => {
// homeserver is a Synapse/Dendrite instance
});
```
This returns an object with information about the Homeserver instance, including what port
it was started on and the ID that needs to be passed to shut it down again. It also
returns the registration shared secret (`registrationSecret`) that can be used to
register users via the REST API. The Homeserver has been ensured ready to go by awaiting
its internal health-check.
Homeserver instances should be reasonably cheap to start (you may see the first one take a
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).
To acquire a logged-in user within a test just add the `user` fixture to the test:
```typescript
test("should do something", async ({ user }) => {
// user is a logged in user
});
```
You can specify a display name for the user via `test.use` `displayName`,
otherwise a random one will be generated.
This will register a random userId using the registrationSecret with a random password
and the given display name. The user fixture will contain details about the credentials for if
they are needed for User-Interactive Auth or similar but localStorage will already be seeded with them
and the app loaded (path `/`).
### Joining a Room
Many tests will also want to start with the client in a room, ready to send & receive messages. Best
way to do this may be to get an access token for the user and use this to create a room with the REST
API before logging the user in.
You can make use of the bot fixture and the `client` field on the app fixture to do this.
### Try to write tests from the users' perspective
Like for instance a user will not look for a button by querying a CSS selector.
Instead, you should work with roles / labels etc, see https://playwright.dev/docs/locators.
### Using matrix-js-sdk
Due to the way we run the Playwright tests in CI, at this time you can only use the matrix-js-sdk module
exposed on `window.matrixcs`. This has the limitation that it is only accessible with the app loaded.
This may be revisited in the future.
## Good Test Hygiene
This section mostly summarises general good Playwright testing practice, and should not be news to anyone
already familiar with Playwright.
1. Test a well-isolated unit of functionality. The more specific, the easier it will be to tell what's
wrong when they fail.
1. Don't depend on state from other tests: any given test should be able to run in isolation.
1. Try to avoid driving the UI for anything other than the UI you're trying to test. e.g. if you're
testing that the user can send a reaction to a message, it's best to send a message using a REST
API, then react to it using the UI, rather than using the element-web UI to send the message.
1. Avoid explicit waits. Playwright locators & assertions will implicitly wait for the specified
element to appear and all assertions are retried until they either pass or time out, so you should
never need to manually wait for an element.
- For example, for asserting about editing an already-edited message, you can't wait for the
'edited' element to appear as there was already one there, but you can assert that the body
of the message is what is should be after the second edit and this assertion will pass once
it becomes true. You can then assert that the 'edited' element is still in the DOM.
- You can also wait for other things like network requests in the
browser to complete (https://playwright.dev/docs/api/class-page#page-wait-for-response).
Needing to wait for things can also be because of race conditions in the app itself, which ideally
shouldn't be there!
This is a small selection - the Playwright best practices guide, linked above, has more good advice, and we
should generally try to adhere to them.
## Screenshot testing
When we previously used Cypress we also dabbled with Percy, and whilst powerful it did not
lend itself well to being executed on all PRs without needing to budget it substantially.
Playwright has built-in support for [visual comparison testing](https://playwright.dev/docs/test-snapshots).
Screenshots are saved in `playwright/snapshots` and are rendered in a Linux Docker environment for stability.
One must be careful to exclude any dynamic content from the screenshot, such as timestamps, avatars, etc,
via the `mask` option. See the [Playwright docs](https://playwright.dev/docs/test-snapshots#masking).
Some UI elements render differently between test runs, such as BaseAvatar when
there is no avatar set, choosing a colour from the theme palette based on the
hash of the user/room's Matrix ID. To avoid this creating flaky tests we inject
some custom CSS, for this to happen we use the custom assertion `toMatchScreenshot`
instead of the native `toHaveScreenshot`.
If you are running Linux and are unfortunate that the screenshots are not rendering identically,
you may wish to specify `--ignore-snapshots` and rely on Docker to render them for you.

View file

@ -22,7 +22,7 @@ The master branch is the most stable as it is the very latest non-RC release. De
<details><summary><h1>Versions</h1></summary><blockquote>
The matrix-js-sdk follows semver, the matrix-react-sdk loosely follows semver, most releases for both will bump the minor version number.
The matrix-js-sdk follows semver, most releases will bump the minor version number.
Breaking changes will bump the major version number.
Element Web & Element Desktop do not follow semver and always have matching version numbers. The patch version number is normally incremented for every release.
@ -80,11 +80,10 @@ This label will automagically convert to `X-Release-Blocker` at the conclusion o
<details><summary><h1>Repositories</h1></summary><blockquote>
This release process revolves around our four main repositories:
This release process revolves around our main repositories:
- [Element Desktop](https://github.com/element-hq/element-desktop/)
- [Element Web](https://github.com/element-hq/element-web/)
- [Matrix React SDK](https://github.com/matrix-org/matrix-react-sdk/)
- [Matrix JS SDK](https://github.com/matrix-org/matrix-js-sdk/)
We own other repositories, but they have more ad-hoc releases and are not part of the bi-weekly cycle:
@ -117,14 +116,13 @@ flowchart TD
subgraph Releasing
R1[[Releasing matrix-js-sdk]]
R2[[Releasing matrix-react-sdk]]
R3[[Releasing element-web]]
R4[[Releasing element-desktop]]
R2[[Releasing element-web]]
R3[[Releasing element-desktop]]
R1 --> R2 --> R3 --> R4
R1 --> R2 --> R3
end
R4 --> D1
R3 --> D1
subgraph Deploying
D1[\Deploy staging.element.io/]
@ -198,12 +196,6 @@ switched back to the version of the dependency from the master branch to not lea
- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft**
- [ ] Kick off a release using [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options.
### Matrix React SDK
- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/matrix-react-sdk/actions/workflows/release-drafter.yml)
- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft**
- [ ] Kick off a release using [the automation](https://github.com/element-hq/matrix-react-sdk/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options.
### Element Web
- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-web/actions/workflows/release-drafter.yml)
@ -256,8 +248,6 @@ For the first RC of a given release cycle do these steps:
- [ ] Go to the [matrix-js-sdk Renovate dashboard](https://github.com/matrix-org/matrix-js-sdk/issues/2406) and click the checkbox to create/update its PRs.
- [ ] Go to the [matrix-react-sdk Renovate dashboard](https://github.com/element-hq/matrix-react-sdk/issues/6) and click the checkbox to create/update its PRs.
- [ ] Go to the [element-web Renovate dashboard](https://github.com/element-hq/element-web/issues/22941) and click the checkbox to create/update its PRs.
- [ ] Go to the [element-desktop Renovate dashboard](https://github.com/element-hq/element-desktop/issues/465) and click the checkbox to create/update its PRs.

165
docs/room-list-store.md Normal file
View file

@ -0,0 +1,165 @@
# Room list sorting
It's so complicated it needs its own README.
![](img/RoomListStore2.png)
Legend:
- Orange = External event.
- Purple = Deterministic flow.
- Green = Algorithm definition.
- Red = Exit condition/point.
- Blue = Process definition.
## Algorithms involved
There's two main kinds of algorithms involved in the room list store: list ordering and tag sorting.
Throughout the code an intentional decision has been made to call them the List Algorithm and Sorting
Algorithm respectively. The list algorithm determines the primary ordering of a given tag whereas the
tag sorting defines how rooms within that tag get sorted, at the discretion of the list ordering.
Behaviour of the overall room list (sticky rooms, etc) are determined by the generically-named Algorithm
class. Here is where much of the coordination from the room list store is done to figure out which list
algorithm to call, instead of having all the logic in the room list store itself.
Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm
the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm,
later described in this document, heavily uses the list ordering behaviour to break the tag into categories.
Each category then gets sorted by the appropriate tag sorting algorithm.
### Tag sorting algorithm: Alphabetical
When used, rooms in a given tag will be sorted alphabetically, where the alphabet's order is a problem
for the browser. All we do is a simple string comparison and expect the browser to return something
useful.
### Tag sorting algorithm: Manual
Manual sorting makes use of the `order` property present on all tags for a room, per the
[Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values
of `order` cause rooms to appear closer to the top of the list.
### Tag sorting algorithm: Recent
Rooms get ordered by the timestamp of the most recent useful message. Usefulness is yet another algorithm
in the room list system which determines whether an event type is capable of bubbling up in the room list.
Normally events like room messages, stickers, and room security changes will be considered useful enough
to cause a shift in time.
Note that this is reliant on the event timestamps of the most recent message. Because Matrix is eventually
consistent this means that from time to time a room might plummet or skyrocket across the tag due to the
timestamp contained within the event (generated server-side by the sender's server).
### List ordering algorithm: Natural
This is the easiest of the algorithms to understand because it does essentially nothing. It imposes no
behavioural changes over the tag sorting algorithm and is by far the simplest way to order a room list.
Historically, it's been the only option in Element and extremely common in most chat applications due to
its relative deterministic behaviour.
### List ordering algorithm: Importance
On the other end of the spectrum, this is the most complicated algorithm which exists. There's major
behavioural changes, and the tag sorting algorithm gets selectively applied depending on circumstances.
Each tag which is not manually ordered gets split into 4 sections or "categories". Manually ordered tags
simply get the manual sorting algorithm applied to them with no further involvement from the importance
algorithm. There are 4 categories: Red, Grey, Bold, and Idle. Each has their own definition based off
relative (perceived) importance to the user:
- **Red**: The room has unread mentions waiting for the user.
- **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread
messages which cause a push notification or badge count. Typically, this is the default as rooms get
set to 'All Messages'.
- **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without
a badge/notification count (or 'Mentions Only'/'Muted').
- **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user
last read it.
Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey
above bold, etc.
Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm
gets applied to each category in a sub-list fashion. This should result in the red rooms (for example)
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
collectively the tag will be sorted into categories with red being at the top.
## Sticky rooms
When the user visits a room, that room becomes 'sticky' in the list, regardless of ordering algorithm.
From a code perspective, the underlying algorithm is not aware of a sticky room and instead the base class
manages which room is sticky. This is to ensure that all algorithms handle it the same.
The sticky flag is simply to say it will not move higher or lower down the list while it is active. For
example, if using the importance algorithm, the room would naturally become idle once viewed and thus
would normally fly down the list out of sight. The sticky room concept instead holds it in place, never
letting it fly down until the user moves to another room.
Only one room can be sticky at a time. Room updates around the sticky room will still hold the sticky
room in place. The best example of this is the importance algorithm: if the user has 3 red rooms and
selects the middle room, they will see exactly one room above their selection at all times. If they
receive another notification which causes the room to move into the topmost position, the room that was
above the sticky room will move underneath to allow for the new room to take the top slot, maintaining
the sticky room's position.
Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries
and thus the user can see a shift in what kinds of rooms move around their selection. An example would
be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having
the rooms above it read on another device. This would result in 1 red room and 1 other kind of room
above the sticky room as it will try to maintain 2 rooms above the sticky room.
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain
the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed.
The N value will never increase while selection remains unchanged: adding a bunch of rooms after having
put the sticky room in a position where it's had to decrease N will not increase N.
## Responsibilities of the store
The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets
an object containing the tags it needs to worry about and the rooms within. The room list component will
decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with
all kinds of filtering.
## Filtering
Filters are provided to the store as condition classes and have two major kinds: Prefilters and Runtime.
Prefilters flush out rooms which shouldn't appear to the algorithm implementations. Typically this is
due to some higher order room list filtering (such as spaces or tags) deliberately exposing a subset of
rooms to the user. The algorithm implementations will not see a room being prefiltered out.
Runtime filters are used for more dynamic filtering, such as the user filtering by room name. These
filters are passed along to the algorithm implementations where those implementations decide how and
when to apply the filter. In practice, the base `Algorithm` class ends up doing the heavy lifting for
optimization reasons.
The results of runtime filters get cached to avoid needlessly iterating over potentially thousands of
rooms, as the old room list store does. When a filter condition changes, it emits an update which (in this
case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a
minor subset where possible to avoid over-iterating rooms.
All filter conditions are considered "stable" by the consumers, meaning that the consumer does not
expect a change in the condition unless the condition says it has changed. This is intentional to
maintain the caching behaviour described above.
One might ask why we don't just use prefilter conditions for everything, and the answer is one of slight
subtlety: in the cases of prefilters we are knowingly exposing the user to a workspace-style UX where
room notifications are self-contained within that workspace. Runtime filters tend to not want to affect
visible notification counts (as it doesn't want the room header to suddenly be confusing to the user as
they type), and occasionally UX like "found 2/12 rooms" is desirable. If prefiltering were used instead,
the notification counts would vary while the user was typing and "found 2/12" UX would not be possible.
## Class breakdowns
The `RoomListStore` is the major coordinator of various algorithm implementations, which take care
of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible
for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get
defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the
user). Various list-specific utilities are also included, though they are expected to move somewhere
more general when needed. For example, the `membership` utilities could easily be moved elsewhere
as needed.
The various bits throughout the room list store should also have jsdoc of some kind to help describe
what they do and how they work.

27
docs/scrolling.md Normal file
View file

@ -0,0 +1,27 @@
# ScrollPanel
## Updates
During an onscroll event, we check whether we're getting close to the top or bottom edge of the loaded content. If close enough, we fire a request to load more through the callback passed in the `onFillRequest` prop. This returns a promise is passed down from `TimelinePanel`, where it will call paginate on the `TimelineWindow` and once the events are received back, update its state with the new events. This update trickles down to the `MessagePanel`, which rerenders all tiles and passed that to `ScrollPanel`. ScrollPanels `componentDidUpdate` method gets called, and we do the scroll housekeeping there (read below). Once the rerender has completed, the `setState` callback is called and we resolve the promise returned by `onFillRequest`. Now we check the DOM to see if we need more fill requests.
## Prevent Shrinking
ScrollPanel supports a mode to prevent it shrinking. This is used to prevent a jump when at the bottom of the timeline and people start and stop typing. It gets cleared automatically when 200px above the bottom of the timeline.
## BACAT (Bottom-Aligned, Clipped-At-Top) scrolling
BACAT scrolling implements a different way of restoring the scroll position in the timeline while tiles out of view are changing height or tiles are being added or removed. It was added in https://github.com/matrix-org/matrix-react-sdk/pull/2842.
The motivation for the changes is having noticed that setting scrollTop while scrolling tends to not work well, with it interrupting ongoing scrolling and also querying scrollTop reporting outdated values and consecutive scroll adjustments cancelling each out previous ones. This seems to be worse on macOS than other platforms, presumably because of a higher resolution in scroll events there. Also see https://github.com/vector-im/element-web/issues/528. The BACAT approach allows to only have to change the scroll offset when adding or removing tiles.
The approach taken instead is to vertically align the timeline tiles to the bottom of the scroll container (using flexbox) and give the timeline inside the scroll container an explicit height, initially set to a multiple of the PAGE_SIZE (400px at time of writing) as needed by the content. When scrolled up, we can compensate for anything that grew below the viewport by changing the height of the timeline to maintain what's currently visible in the viewport without adjusting the scrollTop and hence without jumping.
For anything above the viewport growing or shrinking, we don't need to do anything as the timeline is bottom-aligned. We do need to update the height manually to keep all content visible as more is loaded. To maintain scroll position after the portion above the viewport changes height, we need to set the scrollTop, as we cannot balance it out with more height changes. We do this 100ms after the user has stopped scrolling, so setting scrollTop has not nasty side-effects.
As of https://github.com/matrix-org/matrix-react-sdk/pull/4166, we are scrolling to compensate for height changes by calling `scrollBy(0, x)` rather than reading and than setting `scrollTop`, as reading `scrollTop` can (again, especially on macOS) easily return values that are out of sync with what is on the screen, probably because scrolling can be done [off the main thread](https://wiki.mozilla.org/Platform/GFX/APZ) in some circumstances. This seems to further prevent jumps.
### How does it work?
`componentDidUpdate` is called when a tile in the timeline is updated (as we rerender the whole timeline) or tiles are added or removed (see Updates section before). From here, `checkScroll` is called, which calls `restoreSavedScrollState`. Now, we increase the timeline height if something below the viewport grew by adjusting `this.bottomGrowth`. `bottomGrowth` is the height added to the timeline (on top of the height from the number of pages calculated at the last `updateHeight` run) to compensate for growth below the viewport. This is cleared during the next run of `updateHeight`. Remember that the tiles in the timeline are aligned to the bottom.
From `restoreSavedScrollState` we also call `updateHeight` which waits until the user stops scrolling for 100ms and then recalculates the amount of pages of 400px the timeline should be sized to, to be able to show all of its (newly added) content. We have to adjust the scroll offset (which is why we wait until scrolling has stopped) now because the space above the viewport has likely changed.

236
docs/settings.md Normal file
View file

@ -0,0 +1,236 @@
# Settings Reference
This document serves as developer documentation for using "Granular Settings". Granular Settings allow users to specify
different values for a setting at particular levels of interest. For example, a user may say that in a particular room
they want URL previews off, but in all other rooms they want them enabled. The `SettingsStore` helps mask the complexity
of dealing with the different levels and exposes easy to use getters and setters.
## Levels
Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in
order of priority, are:
- `device` - The current user's device
- `room-device` - The current user's device, but only when in a specific room
- `room-account` - The current user's account, but only when in a specific room
- `account` - The current user's account
- `room` - A specific room (setting for all members of the room)
- `config` - Values are defined by the `setting_defaults` key (usually) in `config.json`
- `default` - The hardcoded default for the settings
Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure
that room administrators cannot force account-only settings upon participants.
## Settings
Settings are the different options a user may set or experience in the application. These are pre-defined in
`src/settings/Settings.tsx` under the `SETTINGS` constant, and match the `ISetting` interface as defined there.
Settings that support the config level can be set in the config file under the `setting_defaults` key (note that some
settings, like the "theme" setting, are special cased in the config file):
```json5
{
...
"setting_defaults": {
"settingName": true
},
...
}
```
### Getting values for a setting
After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always
be supplied where possible, even if the setting does not have a per-room level value. This is to ensure that the value
returned is best represented in the room, particularly if the setting ever gets a per-room level in the future.
In settings pages it is often desired to have the value at a particular level instead of getting the calculated value.
Call `SettingsStore.getValueAt` to get the value of a setting at a particular level, and optionally make it explicitly
at that level. By default `getValueAt` will traverse the tree starting at the provided level; making it explicit means
it will not go beyond the provided level. When using `getValueAt`, please be sure to use `SettingLevel` to represent the
target level.
### Setting values for a setting
Values are defined at particular levels and should be done in a safe manner. There are two checks to perform to ensure a
clean save: is the level supported and can the user actually set the value. In most cases, neither should be an issue
although there are circumstances where this changes. An example of a safe call is:
```javascript
const isSupported = SettingsStore.isLevelSupported(SettingLevel.ROOM);
if (isSupported) {
const canSetValue = SettingsStore.canSetValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM);
if (canSetValue) {
SettingsStore.setValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM, newValue);
}
}
```
These checks may also be performed in different areas of the application to avoid the verbose example above. For
instance, the component which allows changing the setting may be hidden conditionally on the above conditions.
##### `SettingsFlag` component
Where possible, the `SettingsFlag` component should be used to set simple "flip-a-bit" (true/false) settings. The
`SettingsFlag` also supports simple radio button options, such as the theme the user would like to use.
```TSX
<SettingsFlag name="theSettingId" level={SettingsLevel.ROOM} roomId="!curbf:matrix.org"
label={_td("Your label here")} // optional, if falsey then the `SettingsStore` will be used
onChange={function(newValue) { }} // optional, called after saving
isExplicit={false} // this is passed along to `SettingsStore.getValueAt`, defaulting to false
manualSave={false} // if true, saving is delayed. You will need to call .save() on this component
// Options for radio buttons
group="your-radio-group" // this enables radio button support
value="yourValueHere" // the value for this particular option
/>
```
### Getting the display name for a setting
Simply call `SettingsStore.getDisplayName`. The appropriate display name will be returned and automatically translated
for you. If a display name cannot be found, it will return `null`.
## Features
Feature flags are just like regular settings with some underlying semantics for how they are meant to be used. Usually
a feature flag is used when a portion of the application is under development or not ready for full release yet, such
as new functionality or experimental ideas. In these cases, the feature name _should_ be named with the `feature_*`
convention and must be tagged with `isFeature: true` in the setting definition. By doing so, the feature will automatically
appear in the "labs" section of the user's settings.
Features can be controlled at the config level using the following structure:
```json
"features": {
"feature_lazyloading": true
}
```
When `true`, the user will see the feature as enabled. Similarly, when `false` the user will see the feature as disabled.
The user will only be able to change/see these states if `show_labs_settings: true` is in the config.
### Determining if a feature is enabled
Call `SettingsStore.getValue()` as you would for any other setting.
### Enabling a feature
Call `SettingsStore.setValue("feature_name", null, SettingLevel.DEVICE, true)`.
### A note on UI features
UI features are a different concept to plain features. Instead of being representative of unstable or
unpredicatable behaviour, they are logical chunks of UI which can be disabled by deployments for ease
of understanding with users. They are simply represented as boring settings with a convention of being
named as `UIFeature.$location` where `$location` is a rough descriptor of what is toggled, such as
`URLPreviews` or `Communities`.
UI features also tend to have their own setting controller (see below) to manipulate settings which might
be affected by the UI feature being disabled. For example, if URL previews are disabled as a UI feature
then the URL preview options will use the `UIFeatureController` to ensure they remain disabled while the
UI feature is disabled.
## Setting controllers
Settings may have environmental factors that affect their value or need additional code to be called when they are
modified. A setting controller is able to override the calculated value for a setting and react to changes in that
setting. Controllers are not a replacement for the level handlers and should only be used to ensure the environment is
kept up to date with the setting where it is otherwise not possible. An example of this is the notification settings:
they can only be considered enabled if the platform supports notifications, and enabling notifications requires
additional steps to actually enable notifications.
For more information, see `src/settings/controllers/SettingController.ts`.
## Local echo
`SettingsStore` will perform local echo on all settings to ensure that immediately getting values does not cause a
split-brain scenario. As mentioned in the "Setting values for a setting" section, the appropriate checks should be done
to ensure that the user is allowed to set the value. The local echo system assumes that the user has permission and that
the request will go through successfully. The local echo only takes effect until the request to save a setting has
completed (either successfully or otherwise).
```javascript
SettingsStore.setValue(...).then(() => {
// The value has actually been stored at this point.
});
SettingsStore.getValue(...); // this will return the value set in `setValue` above.
```
## Watching for changes
Most use cases do not need to set up a watcher because they are able to react to changes as they are made, or the
changes which are made are not significant enough for it to matter. Watchers are intended to be used in scenarios where
it is important to react to changes made by other logged in devices. Typically, this would be done within the component
itself, however the component should not be aware of the intricacies of setting inversion or remapping to particular
data structures. Instead, a generic watcher interface is provided on `SettingsStore` to watch (and subsequently unwatch)
for changes in a setting.
An example of a watcher in action would be:
```javascript
class MyComponent extends React.Component {
settingWatcherRef = null;
componentDidMount() {
const callback = (settingName, roomId, level, newValAtLevel, newVal) => {
this.setState({ color: newVal });
};
this.settingWatcherRef = SettingsStore.watchSetting("roomColor", "!example:matrix.org", callback);
}
componentWillUnmount() {
SettingsStore.unwatchSetting(this.settingWatcherRef);
}
}
```
# Maintainers Reference
The granular settings system has a few complex parts to power it. This section is to document how the `SettingsStore` is
supposed to work.
### General information
The `SettingsStore` uses the hardcoded `LEVEL_ORDER` constant to ensure that it is using the correct override procedure.
The array is checked from left to right, simulating the behaviour of overriding values from the higher levels. Each
level should be defined in this array, including `default`.
Handlers (`src/settings/handlers/SettingsHandler.ts`) represent a single level and are responsible for getting and
setting values at that level. Handlers also provide additional information to the `SettingsStore` such as if the level
is supported or if the current user may set values at the level. The `SettingsStore` will use the handler to enforce
checks and manipulate settings. Handlers are also responsible for dealing with migration patterns or legacy settings for
their level (for example, a setting being renamed or using a different key from other settings in the underlying store).
Handlers are provided to the `SettingsStore` via the `LEVEL_HANDLERS` constant. `SettingsStore` will optimize lookups by
only considering handlers that are supported on the platform.
Local echo is achieved through `src/settings/handlers/LocalEchoWrapper.ts` which acts as a wrapper around a given
handler. This is automatically applied to all defined `LEVEL_HANDLERS` and proxies the calls to the wrapped handler
where possible. The echo is achieved by a simple object cache stored within the class itself. The cache is invalidated
immediately upon the proxied save call succeeding or failing.
Controllers are notified of changes by the `SettingsStore`, and are given the opportunity to override values after the
`SettingsStore` has deemed the value calculated. Controllers are invoked as the last possible step in the code.
### Features
See above for feature reference.
### Watchers
Watchers can appear complicated under the hood: there is a central `WatchManager` which handles the actual invocation
of callbacks, and callbacks are managed by the SettingsStore by redirecting the caller's callback to a dedicated
callback. This is done so that the caller can reuse the same function as their callback without worrying about whether
or not it'll unsubscribe all watchers.
Setting changes are emitted into the default `WatchManager`, which calculates the new value for the setting. Ideally,
we'd also try and suppress updates which don't have a consequence on this value, however there's not an easy way to do
this. Instead, we just dispatch an update for all changes and leave it up to the consumer to deduplicate.
In practice, handlers which rely on remote changes (account data, room events, etc) will always attach a listener to the
`MatrixClient`. They then watch for changes to events they care about and send off appropriate updates to the
generalized `WatchManager` - a class specifically designed to deduplicate the logic of managing watchers. The handlers
which are localized to the local client (device) generally just trigger the `WatchManager` when they manipulate the
setting themselves as there's nothing to really 'watch'.

18
docs/skinning.md Normal file
View file

@ -0,0 +1,18 @@
# Skinning
Skinning in the context of the react-sdk is component replacement rather than CSS. This means you can override (replace)
any accessible component in the project to implement custom behaviour, look & feel, etc. Depending on your approach,
overriding CSS classes to apply custom styling is also possible, though harder to do.
At present, the react-sdk offers no stable interface for components - this means properties and state can and do change
at any time without notice. Once we determine the react-sdk to be stable enough to use as a proper SDK, we will adjust
this policy. In the meantime, skinning is done completely at your own risk.
The approach you take is up to you - we suggest using a module replacement plugin, as found in
[webpack](https://webpack.js.org/plugins/normal-module-replacement-plugin/), though you're free to use whichever build
system works for you. The react-sdk does not have any particular functions to call to load skins, so simply replace or
extend the components/stores/etc you're after and build. As a reminder though, this is done completely at your own risk
as we cannot guarantee a stable interface at this time.
Taking a look at [element-web](https://github.com/vector-im/element-web)'s approach to skinning may be worthwhile, as it
overrides some relatively simple components.

View file

@ -3,21 +3,17 @@
Themes are a very basic way of providing simple alternative look & feels to the
Element app via CSS & custom imagery.
They are _NOT_ co be confused with 'skins', which describe apps which sit on top
of matrix-react-sdk - e.g. in theory Element itself is a react-sdk skin.
As of March 2022, skins are not fully supported; Element is the only available skin.
To define a theme for Element:
1. Pick a name, e.g. `teal`. at time of writing we have `light` and `dark`.
2. Fork `src/skins/vector/css/themes/dark.pcss` to be `teal.pcss`
3. Fork `src/skins/vector/css/themes/_base.pcss` to be `_teal.pcss`
2. Fork `res/themes/dark/css/dark.pcss` to be `teal.pcss`
3. Fork `res/themes/dark/css/_base.pcss` to be `_teal.pcss`
4. Override variables in `_teal.pcss` as desired. You may wish to delete ones
which don't differ from `_base.pcss`, to make it clear which are being
overridden. If every single colour is being changed (as per `_dark.pcss`)
then you might as well keep them all.
5. Add the theme to the list of entrypoints in webpack.config.js
6. Add the theme to the list of themes in matrix-react-sdk's UserSettings.js
6. Add the theme to the list of themes in theme.ts
7. Sit back and admire your handywork.
In future, the assets for a theme will probably be gathered together into a

View file

@ -3,13 +3,12 @@
## Requirements
- A working [Development Setup](../README.md#setting-up-a-dev-environment)
- Including up-to-date versions of matrix-react-sdk and matrix-js-sdk
- Latest LTS version of Node.js installed
- Be able to understand English
## Translating strings vs. marking strings for translation
Translating strings are done with the `_t()` function found in matrix-react-sdk/lib/languageHandler.js.
Translating strings are done with the `_t()` function found in `languageHandler.tsx`.
It is recommended to call this function wherever you introduce a string constant which should be translated.
However, translating can not be performed until after the translation system has been initialized.
Thus, sometimes translation must be performed at a different location in the source code than where the string is introduced.
@ -49,7 +48,7 @@ We are aiming for a set of common strings to be shared then some more localised
## Adding new strings
1. Check if the import `import { _t } from 'matrix-react-sdk/src/languageHandler';` is present. If not add it to the other import statements. Also import `_td` if needed.
1. Check if the import `import { _t } from ".../languageHandler";` is present. If not add it to the other import statements. Also import `_td` if needed.
1. Add `_t()` to your string passing the translation key you come up with based on the rules above. If the string is introduced at a point before the translation system has not yet been initialized, use `_td()` instead, and call `_t()` at the appropriate time.
1. Run `yarn i18n` to add the keys to `src/i18n/strings/en_EN.json`
1. Modify the new entries in `src/i18n/strings/en_EN.json` with the English (UK) translations for the added keys.

27
docs/usercontent.md Normal file
View file

@ -0,0 +1,27 @@
# Usercontent
While decryption itself is safe to be done without a sandbox,
letting the browser and user interact with the resulting data may be dangerous,
previously `usercontent.riot.im` was used to act as a sandbox on a different origin to close the attack surface,
it is now possible to do by using a combination of a sandboxed iframe and some code written into the app which consumes this SDK.
Usercontent is an iframe sandbox target for allowing a user to safely download a decrypted attachment from a sandboxed origin where it cannot be used to XSS your Element session out from under you.
Its function is to create an Object URL for the user/browser to use but bound to an origin different to that of the Element instance to protect against XSS.
It exposes a function over a postMessage API, when sent an object with the matching fields to render a download link with the Object URL:
```json5
{
imgSrc: "", // the src of the image to display in the download link
imgStyle: "", // the style to apply to the image
style: "", // the style to apply to the download link
download: "", // download attribute to pass to the <a/> tag
textContent: "", // the text to put inside the download link
blob: "", // the data blob to wrap in an object url and allow the user to download
}
```
If only imgSrc, imgStyle and style are passed then just update the existing link without overwriting other things about it.
It is expected that this target be available at `usercontent/` relative to the root of the app, this can be seen in element-web's webpack config.

61
docs/widget-layouts.md Normal file
View file

@ -0,0 +1,61 @@
# Widget layout support
Rooms can have a default widget layout to auto-pin certain widgets, make the container different
sizes, etc. These are defined through the `io.element.widgets.layout` state event (empty state key).
Full example content:
```json5
{
widgets: {
"first-widget-id": {
container: "top",
index: 0,
width: 60,
height: 40,
},
"second-widget-id": {
container: "right",
},
},
}
```
As shown, there are two containers possible for widgets. These containers have different behaviour
and interpret the other options differently.
## `top` container
This is the "App Drawer" or any pinned widgets in a room. This is by far the most versatile container
though does introduce potential usability issues upon members of the room (widgets take up space and
therefore fewer messages can be shown).
The `index` for a widget determines which order the widgets show up in from left to right. Widgets
without an `index` will show up as the rightmost widgets. Tiebreaks (same `index` or multiple defined
without an `index`) are resolved by comparing widget IDs. A maximum of 3 widgets can be in the top
container - any which exceed this will be ignored (placed into the `right` container). Smaller numbers
represent leftmost widgets.
The `width` is relative width within the container in percentage points. This will be clamped to a
range of 0-100 (inclusive). The widgets will attempt to scale to relative proportions when more than
100% space is allocated. For example, if 3 widgets are defined at 40% width each then the client will
attempt to show them at 33% width each.
Note that the client may impose minimum widths on the widgets, such as a 10% minimum to avoid pinning
hidden widgets. In general, widgets defined in the 30-70% range each will be free of these restrictions.
The `height` is not in fact applied per-widget but is recorded per-widget for potential future
capabilities in future containers. The top container will take the tallest `height` and use that for
the height of the whole container, and thus all widgets in that container. The `height` is relative
to the container, like with `width`, meaning that 100% will consume as much space as the client is
willing to sacrifice to the widget container. Like with `width`, the client may impose minimums to avoid
the container being uselessly small. Heights in the 30-100% range are generally acceptable. The height
is also clamped to be within 0-100, inclusive.
## `right` container
This is the default container and has no special configuration. Widgets which overflow from the top
container will be put in this container instead. Putting a widget in the right container does not
automatically show it - it only mentions that widgets should not be in another container.
The behaviour of this container may change in the future.

View file

@ -16,35 +16,50 @@ const config: Config = {
url: "http://localhost/",
},
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
setupFiles: ["jest-canvas-mock"],
setupFilesAfterEnv: ["<rootDir>/node_modules/matrix-react-sdk/test/setupTests.ts"],
globalSetup: "<rootDir>/test/globalSetup.ts",
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
moduleNameMapper: {
"\\.(css|scss|pcss)$": "<rootDir>/__mocks__/cssMock.js",
"\\.(gif|png|ttf|woff2)$": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/imageMock.js",
"\\.svg$": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/svg.js",
"\\$webapp/i18n/languages.json": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/languages.json",
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
"\\.svg$": "<rootDir>/__mocks__/svg.js",
"\\$webapp/i18n/languages.json": "<rootDir>/__mocks__/languages.json",
"^react$": "<rootDir>/node_modules/react",
"^react-dom$": "<rootDir>/node_modules/react-dom",
"^matrix-js-sdk$": "<rootDir>/node_modules/matrix-js-sdk/src",
"^matrix-react-sdk$": "<rootDir>/node_modules/matrix-react-sdk/src",
"decoderWorker\\.min\\.js": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js",
"decoderWorker\\.min\\.wasm": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js",
"waveWorker\\.min\\.js": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js",
"context-filter-polyfill": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js",
"FontManager.ts": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/FontManager.js",
"workers/(.+)Factory": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/workerFactoryMock.js",
"^matrix-react-sdk$": "<rootDir>/src",
"decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js",
"waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"context-filter-polyfill": "<rootDir>/__mocks__/empty.js",
"FontManager.ts": "<rootDir>/__mocks__/FontManager.js",
"workers/(.+)Factory": "<rootDir>/__mocks__/workerFactoryMock.js",
"^!!raw-loader!.*": "jest-raw-loader",
"recorderWorkletFactory": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js",
"recorderWorkletFactory": "<rootDir>/__mocks__/empty.js",
"^fetch-mock$": "<rootDir>/node_modules/fetch-mock",
},
transformIgnorePatterns: ["/node_modules/(?!matrix-js-sdk).+$", "/node_modules/(?!matrix-react-sdk).+$"],
transformIgnorePatterns: ["/node_modules/(?!matrix-js-sdk).+$"],
collectCoverageFrom: [
"<rootDir>/src/**/*.{js,ts,tsx}",
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is
// not available in that contest. So, turn off coverage instrumentation for it.
"!<rootDir>/src/utils/SessionLock.ts",
],
coverageReporters: ["text-summary", "lcov"],
testResultsProcessor: "@casualbot/jest-sonar-reporter",
prettierPath: null,
moduleDirectories: ["node_modules", "test/test-utils"],
};
// if we're running under GHA, enable the GHA reporter
if (env["GITHUB_ACTIONS"] !== undefined) {
config.reporters = [["github-actions", { silent: false }], "summary"];
const reporters: Config["reporters"] = [["github-actions", { silent: false }], "summary"];
// if we're running against the develop branch, also enable the slow test reporter
if (env["GITHUB_REF"] == "refs/heads/develop") {
reporters.push("<rootDir>/test/slowReporter.cjs");
}
config.reporters = reporters;
}
export default config;

View file

@ -32,8 +32,10 @@
"scripts": {
"i18n": "matrix-gen-i18n && yarn i18n:sort && yarn i18n:lint",
"i18n:sort": "jq --sort-keys '.' src/i18n/strings/en_EN.json > src/i18n/strings/en_EN.json.tmp && mv src/i18n/strings/en_EN.json.tmp src/i18n/strings/en_EN.json",
"i18n:lint": "prettier --log-level=silent --write src/i18n/strings/ --ignore-path /dev/null",
"i18n:lint": "matrix-i18n-lint && prettier --log-level=silent --write src/i18n/strings/ --ignore-path /dev/null",
"i18n:diff": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && yarn i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json",
"make-component": "node scripts/make-react-component.js",
"rethemendex": "res/css/rethemendex.sh",
"clean": "rimraf lib webapp",
"build": "yarn clean && yarn build:genfiles && yarn build:bundle",
"build-stats": "yarn clean && yarn build:genfiles && yarn build:bundle-stats",
@ -50,17 +52,22 @@
"start:js": "webpack serve --output-path webapp --output-filename=bundles/_dev_/[name].js --output-chunk-filename=bundles/_dev_/[name].js --mode development",
"lint": "yarn lint:types && yarn lint:js && yarn lint:style && yarn lint:workflows",
"lint:js": "yarn lint:js:src && yarn lint:js:module_system",
"lint:js:src": "eslint --max-warnings 0 src test && prettier --check .",
"lint:js:src": "eslint --max-warnings 0 src test playwright && prettier --check .",
"lint:js:module_system": "eslint --max-warnings 0 --config .eslintrc-module_system.js module_system",
"lint:js-fix": "yarn lint:js-fix:src && yarn lint:js-fix:module_system",
"lint:js-fix:src": "prettier --log-level=warn --write . && eslint --fix src test",
"lint:js-fix:src": "prettier --log-level=warn --write . && eslint --fix src test playwright",
"lint:js-fix:module_system": "eslint --fix --config .eslintrc-module_system.js module_system",
"lint:types": "yarn lint:types:src && yarn lint:types:module_system",
"lint:types:src": "tsc --noEmit --jsx react",
"lint:types:src": "tsc --noEmit --jsx react && tsc --noEmit --jsx react -p playwright",
"lint:types:module_system": "tsc --noEmit --project ./tsconfig.module_system.json",
"lint:style": "stylelint \"res/css/**/*.pcss\"",
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
"test": "jest",
"test:playwright": "playwright test",
"test:playwright:open": "yarn test:playwright --ui",
"test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run",
"test:playwright:screenshots:build": "docker build playwright -t element-web-playwright",
"test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -v $(pwd)/../:/work/ -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright",
"coverage": "yarn test --coverage",
"analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
@ -69,32 +76,97 @@
"resolutions": {
"@types/react-dom": "17.0.25",
"@types/react": "17.0.82",
"@types/seedrandom": "3.0.8",
"oidc-client-ts": "3.0.1",
"jwt-decode": "4.0.0",
"@vector-im/compound-design-tokens": "1.8.0",
"@vector-im/compound-web": "7.0.0",
"@floating-ui/react": "0.26.11",
"@radix-ui/react-id": "1.1.0"
"@radix-ui/react-id": "1.1.0",
"caniuse-lite": "1.0.30001655",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@formatjs/intl-segmenter": "^11.5.7",
"@matrix-org/react-sdk-module-api": "^2.3.0",
"@matrix-org/analytics-events": "^0.26.0",
"@matrix-org/emojibase-bindings": "^1.3.3",
"@vector-im/matrix-wysiwyg": "2.37.13",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^8.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@vector-im/compound-design-tokens": "^1.8.0",
"@vector-im/compound-web": "^7.0.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"await-lock": "^2.1.0",
"bloom-filters": "^3.0.1",
"blurhash": "^2.0.3",
"browserslist": "^4.23.2",
"classnames": "^2.2.6",
"commonmark": "^0.31.0",
"counterpart": "^0.18.6",
"css-tree": "^3.0.0",
"diff-dom": "^5.0.0",
"diff-match-patch": "^1.0.5",
"emojibase-regex": "15.3.2",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "10.1.4",
"github-markdown-css": "^5.5.1",
"glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1",
"html-entities": "^2.0.0",
"is-ip": "^3.1.0",
"jsrsasign": "^11.0.0",
"js-xxhash": "^4.0.0",
"jszip": "^3.7.0",
"katex": "^0.16.0",
"linkify-element": "4.1.3",
"linkify-react": "4.1.3",
"linkify-string": "4.1.3",
"linkifyjs": "4.1.3",
"lodash": "^4.17.21",
"maplibre-gl": "^2.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-react-sdk": "github:matrix-org/matrix-react-sdk#develop",
"matrix-widget-api": "^1.8.2",
"matrix-widget-api": "^1.9.0",
"memoize-one": "^6.0.0",
"oidc-client-ts": "^3.0.1",
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.157.2",
"qrcode": "1.5.4",
"re-resizable": "^6.9.0",
"react": "17.0.2",
"react-beautiful-dnd": "^13.1.0",
"react-blurhash": "^0.3.0",
"react-dom": "17.0.2",
"ua-parser-js": "^1.0.0"
"react-focus-lock": "^2.5.1",
"react-transition-group": "^4.4.1",
"rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3",
"sanitize-html": "2.13.0",
"tar-js": "^0.3.0",
"temporal-polyfill": "^0.2.5",
"ua-parser-js": "^1.0.2",
"uuid": "^10.0.0",
"what-input": "^5.2.10"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",
"@action-validator/core": "^0.6.0",
"@axe-core/playwright": "^4.8.1",
"@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10",
"@babel/eslint-parser": "^7.12.10",
"@babel/eslint-plugin": "^7.12.10",
"@babel/parser": "^7.12.11",
"@babel/plugin-proposal-export-default-from": "^7.12.1",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-class-properties": "^7.12.1",
@ -109,48 +181,60 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10",
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.3.1",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.40.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@sentry/webpack-plugin": "^2.7.1",
"@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^9.0.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^12.1.5",
"@types/commonmark": "^0.27.9",
"@types/content-type": "^1.1.8",
"@types/counterpart": "^0.18.4",
"@types/diff-match-patch": "^1.0.36",
"@types/escape-html": "^1.0.4",
"@types/file-saver": "^2.0.7",
"@types/glob-to-regexp": "^0.4.4",
"@types/jest": "^29.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/commonmark": "^0.27.4",
"@types/content-type": "^1.1.5",
"@types/counterpart": "^0.18.1",
"@types/css-tree": "^2.3.8",
"@types/diff-match-patch": "^1.0.32",
"@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",
"@types/jsrsasign": "^10.5.4",
"@types/katex": "^0.16.7",
"@types/lodash": "^4.14.197",
"@types/katex": "^0.16.0",
"@types/lodash": "^4.14.168",
"@types/minimist": "^1.2.5",
"@types/modernizr": "^3.5.6",
"@types/node": "^16",
"@types/node-fetch": "^2.6.4",
"@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5",
"@types/modernizr": "^3.5.3",
"@types/node": "18",
"@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5",
"@types/react": "17.0.82",
"@types/react-beautiful-dnd": "^13.1.7",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "17.0.25",
"@types/react-transition-group": "^4.4.9",
"@types/sanitize-html": "^2.9.5",
"@types/sdp-transform": "^2.4.9",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.13.0",
"@types/sdp-transform": "^2.4.6",
"@types/seedrandom": "3.0.8",
"@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": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"axe-core": "4.10.0",
"babel-jest": "^29.0.0",
"babel-loader": "^9.0.0",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"blob-polyfill": "^9.0.0",
"buffer": "^6.0.3",
"chokidar": "^4.0.0",
"concurrently": "^9.0.0",
"copy-webpack-plugin": "^12.0.0",
"core-js": "^3.38.1",
"cronstrue": "^2.41.0",
"css-loader": "^7.0.0",
"css-minimizer-webpack-plugin": "^7.0.0",
@ -159,30 +243,39 @@
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-deprecate": "0.8.5",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^28.0.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "1.2.1",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-unicorn": "^55.0.0",
"express": "^4.18.2",
"fake-indexeddb": "^6.0.0",
"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",
"jest": "^29.0.0",
"jest-canvas-mock": "2.5.2",
"jest-environment-jsdom": "^29.0.0",
"jest-mock": "^29.0.0",
"jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.6.2",
"jest-mock": "^29.6.2",
"jest-raw-loader": "^1.0.1",
"lint-staged": "^15.1.0",
"jsqr": "^1.4.0",
"lint-staged": "^15.0.2",
"mailhog": "^4.16.0",
"matrix-mock-request": "^2.5.0",
"matrix-web-i18n": "^3.2.1",
"mini-css-extract-plugin": "2.9.0",
"minimist": "^1.2.6",
"mkdirp": "^3.0.0",
"mocha-junit-reporter": "^2.2.0",
"modernizr": "^3.12.0",
"node-fetch": "^2.6.7",
"playwright-core": "^1.45.1",
"postcss": "8.4.38",
"postcss-easings": "^4.0.0",
"postcss-hexrgba": "2.1.0",
@ -206,6 +299,7 @@
"ts-prune": "^0.10.3",
"typescript": "5.6.2",
"util": "^0.12.5",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.0.0",

38
playwright.config.ts Normal file
View file

@ -0,0 +1,38 @@
/*
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 { defineConfig } from "@playwright/test";
const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080";
export default defineConfig({
use: {
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
video: "retain-on-failure",
baseURL,
permissions: ["clipboard-write", "clipboard-read", "microphone"],
launchOptions: {
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"],
},
trace: "on-first-retry",
},
webServer: {
command: process.env.CI ? "npx serve -p 8080 -L ../webapp" : "yarn --cwd ../element-web start",
url: `${baseURL}/config.json`,
reuseExistingServer: true,
},
testDir: "playwright/e2e",
outputDir: "playwright/test-results",
workers: 1,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [["blob"], ["github"]] : [["html", { outputFolder: "playwright/html-report" }]],
snapshotDir: "playwright/snapshots",
snapshotPathTemplate: "{snapshotDir}/{testFilePath}/{arg}-{platform}{ext}",
forbidOnly: !!process.env.CI,
});

6
playwright/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/test-results/
/html-report/
/logs/
# Only commit snapshots from Linux
/snapshots/**/*.png
!/snapshots/**/*-linux.png

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

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

9
playwright/Dockerfile Normal file
View file

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

View file

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

View file

@ -0,0 +1,158 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot";
test.describe("Landmark navigation tests", () => {
test.use({
displayName: "Alice",
});
test("without any rooms", async ({ page, homeserver, app, user }) => {
/**
* Without any rooms, there is no tile in the roomlist to be focused.
* So the next landmark in the list should be focused instead.
*/
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
// Pressing Control+F6 again will focus the message composer
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
// Pressing Control+F6 again will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Now go back in the same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
test("with an open room", async ({ page, homeserver, app, user }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
await bob.prepareClient();
// create dm with bob
await app.client.evaluate(
async (cli, { bob }) => {
const bobRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob);
},
{
bob: bob.credentials.userId,
},
);
await app.viewRoomByName("Bob");
// confirm the room was loaded
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
// Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
// Pressing Control+F6 again will focus the message composer
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
// Pressing Control+F6 again will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Now go back in the same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
test("without an open room", async ({ page, homeserver, app, user }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
await bob.prepareClient();
// create a dm with bob
await app.client.evaluate(
async (cli, { bob }) => {
const bobRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob);
},
{
bob: bob.credentials.userId,
},
);
await app.viewRoomByName("Bob");
// confirm the room was loaded
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Close the room
page.goto("/#/home");
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
// Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomTile")).toBeFocused();
// Pressing Control+F6 again will focus the home section
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
// Pressing Control+F6 will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Now go back in same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomTile")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
});

View file

@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test(`shows error page if browser lacks Intl support`, async ({ page }) => {
await page.addInitScript({ content: `delete window.Intl;` });
await page.goto("/");
// Lack of Intl support causes the app bundle to fail to load, so we get the iframed
// static error page and need to explicitly look in the iframe because Playwright doesn't
// recurse into iframes when looking for elements
const header = page.frameLocator("iframe").getByText("Unsupported browser");
await expect(header).toBeVisible();
await expect(page).toMatchScreenshot("unsupported-browser.png");
});
test(`shows error page if browser lacks WebAssembly support`, async ({ page }) => {
await page.addInitScript({ content: `delete window.WebAssembly;` });
await page.goto("/");
// Lack of WebAssembly support doesn't cause the bundle to fail loading, so we get
// CompatibilityView, i.e. no iframes.
const header = page.getByText("Element does not support this browser");
await expect(header).toBeVisible();
await expect(page).toMatchScreenshot("unsupported-browser-CompatibilityView.png");
});

View file

@ -0,0 +1,37 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/*
* Tests for application startup with guest registration enabled on the server.
*/
import { expect, test } from "../../element-web-test";
test.use({
startHomeserverOpts: "guest-enabled",
config: async ({ homeserver }, use) => {
await use({
default_server_config: {
"m.homeserver": { base_url: homeserver.config.baseUrl },
},
});
},
});
test("Shows the welcome page by default", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible();
});
test("Room link correctly loads a room view", async ({ page }) => {
await page.goto("/#/room/!room:id");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("heading", { name: "Join the conversation with an account" })).toBeVisible();
});

View file

@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
/*
* Tests for application startup with credentials stored in localstorage.
*/
test.use({ displayName: "Boris" });
test("Shows the homepage by default", async ({ pageWithCredentials: page }) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/home/);
await expect(page.getByRole("heading", { name: "Welcome Boris", exact: true })).toBeVisible();
});
test("Shows the last known page on reload", async ({ pageWithCredentials: page }) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
const app = new ElementAppPage(page);
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
// Navigate away
await page.goto("about:blank");
// And back again
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
// Check that the room reloaded
await expect(page).toHaveURL(/\/#\/room\//);
await expect(page.locator(".mx_RoomHeader")).toContainText("Test Room");
});
test("Room link correctly loads a room view", async ({ pageWithCredentials: page }) => {
await page.goto("/#/room/!room:id");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible();
});
test("Login link redirects to home page", async ({ pageWithCredentials: page }) => {
await page.goto("/#/login");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/home/);
await expect(page.getByRole("heading", { name: "Welcome Boris", exact: true })).toBeVisible();
});

View file

@ -0,0 +1,347 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
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 type { Locator, Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { ElementAppPage } from "../../pages/ElementAppPage";
test.describe("Audio player", () => {
test.use({
displayName: "Hanako",
});
const uploadFile = async (page: Page, file: string) => {
// Upload a file from the message composer
await page.locator(".mx_MessageComposer_actions input[type='file']").setInputFiles(file);
// Find and click primary "Upload" button
await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
// Wait until the file is sent
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
await expect(page.locator(".mx_EventTile.mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible();
// wait for the tile to finish loading
await expect(
page
.locator(".mx_AudioPlayer_mediaName")
.last()
.filter({ hasText: file.split("/").at(-1) }),
).toBeVisible();
};
/**
* Take snapshots of mx_EventTile_last on each layout, outputting log for reference/debugging.
* @param detail The snapshot name. Used for outputting logs too.
* @param monospace This changes the font used to render the UI from a default one to Inconsolata. Set to false by default.
*/
const takeSnapshots = async (page: Page, app: ElementAppPage, detail: string, monospace = false) => {
// Check that the audio player is rendered and its button becomes visible
const checkPlayerVisibility = async (locator: Locator) => {
// Assert that the audio player and media information are visible
const mediaInfo = locator.locator(
".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container .mx_AudioPlayer_mediaInfo",
);
await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName", { hasText: ".ogg" })).toBeVisible(); // extension
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible();
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size
// Assert that the play button can be found and is visible
await expect(locator.getByRole("button", { name: "Play" })).toBeVisible();
if (monospace) {
// Assert that the monospace timer is visible
await expect(locator.locator("[role='timer']")).toHaveCSS("font-family", "Inconsolata");
}
};
if (monospace) {
// Enable system font and monospace setting
await app.settings.setValue("useBundledEmojiFont", null, SettingLevel.DEVICE, false);
await app.settings.setValue("useSystemFont", null, SettingLevel.DEVICE, true);
await app.settings.setValue("systemFont", null, SettingLevel.DEVICE, "Inconsolata");
}
// Check the status of the seek bar
expect(await page.locator(".mx_AudioPlayer_seek input[type='range']").count()).toBeGreaterThan(0);
// Enable IRC layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
const ircTile = page.locator(".mx_EventTile_last[data-layout='irc']");
// Click the event timestamp to highlight EventTile in case it is not visible
await ircTile.locator(".mx_MessageTimestamp").click();
// Assert that rendering of the player settled and the play button is visible before taking a snapshot
await checkPlayerVisibility(ircTile);
const screenshotOptions = {
css: `
/* The timestamp is of inconsistent width depending on the time the test runs at */
.mx_MessageTimestamp {
display: none !important;
}
/* The MAB showing up on hover is not needed for the test */
.mx_MessageActionBar {
display: none !important;
}
`,
mask: [page.locator(".mx_AudioPlayer_seek")],
};
// Take a snapshot of mx_EventTile_last on IRC layout
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
`${detail.replaceAll(" ", "-")}-irc-layout.png`,
screenshotOptions,
);
// Take a snapshot on modern/group layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
const groupTile = page.locator(".mx_EventTile_last[data-layout='group']");
await groupTile.locator(".mx_MessageTimestamp").click();
await checkPlayerVisibility(groupTile);
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
`${detail.replaceAll(" ", "-")}-group-layout.png`,
screenshotOptions,
);
// Take a snapshot on bubble layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
const bubbleTile = page.locator(".mx_EventTile_last[data-layout='bubble']");
await bubbleTile.locator(".mx_MessageTimestamp").click();
await checkPlayerVisibility(bubbleTile);
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
`${detail.replaceAll(" ", "-")}-bubble-layout.png`,
screenshotOptions,
);
};
test.beforeEach(async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
// Wait until configuration is finished
await expect(
page
.locator(".mx_GenericEventListSummary[data-layout='group'] .mx_GenericEventListSummary_summary")
.getByText(`${user.displayName} created and configured the room.`),
).toBeVisible();
});
test("should be correctly rendered - light theme", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme)");
});
test("should be correctly rendered - light theme with monospace font", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme, monospace font)", true); // Enable monospace
});
test("should be correctly rendered - high contrast theme", async ({ page, app }) => {
// Disable system theme in case ThemeWatcher enables the theme automatically,
// so that the high contrast theme can be enabled
await app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
// Enable high contrast manually
const settings = await app.settings.openUserSettings("Appearance");
await settings.getByRole("radio", { name: "High contrast" }).click();
await app.closeDialog();
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (high contrast)");
});
test("should be correctly rendered - dark theme", async ({ page, app }) => {
// Enable dark theme
await app.settings.setValue("theme", null, SettingLevel.ACCOUNT, "dark");
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (dark theme)");
});
test("should play an audio file", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert that the audio player is rendered
const container = page.locator(".mx_EventTile_last .mx_AudioPlayer_container");
// Assert that the counter is zero before clicking the play button
await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Find and click "Play" button, the wait is to make the test less flaky
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
await container.getByRole("button", { name: "Play" }).click();
// Assert that "Pause" button can be found
await expect(container.getByRole("button", { name: "Pause" })).toBeVisible();
// Assert that the timer is reset when the audio file finished playing
await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Assert that "Play" button can be found
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
});
test("should support downloading an audio file", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec.ogg");
const downloadPromise = page.waitForEvent("download");
// Find and click "Download" button on MessageActionBar
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Download" }).click();
// Assert that the file was downloaded
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("1sec.ogg");
});
test("should support replying to audio file with another audio file", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
// Find and click "Reply" button on MessageActionBar
const tile = page.locator(".mx_EventTile_last");
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");
// Assert that the audio player is rendered
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
// Assert that replied audio file is rendered as file button inside ReplyChain
const button = tile.locator(".mx_ReplyChain_wrapper .mx_MFileBody_info[role='button']");
// Assert that the file button has file name
await expect(button.locator(".mx_MFileBody_info_filename")).toBeVisible();
await takeSnapshots(page, app, "Selected EventTile of audio player with a reply");
});
test("should support creating a reply chain with multiple audio files", async ({ page, app, user }) => {
// Note: "mx_ReplyChain" element is used not only for replies which
// create a reply chain, but also for a single reply without a replied
// message. This test checks whether a reply chain which consists of
// multiple audio file replies is rendered properly.
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();
// Reply to the player with another audio file
await uploadFile(page, "playwright/sample-files/upload-second.ogg");
// Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
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");
// Assert that the audio player is rendered
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
// Assert that there are two "mx_ReplyChain" elements
await expect(tile.locator(".mx_ReplyChain")).toHaveCount(2);
// Assert that one line contains the user name
await expect(tile.locator(".mx_ReplyChain .mx_ReplyTile_sender").getByText(user.displayName)).toBeVisible();
// Assert that the other line contains the file button
await expect(tile.locator(".mx_ReplyChain .mx_MFileBody")).toBeVisible();
// Click "In reply to"
await tile.locator(".mx_ReplyChain .mx_ReplyChain_show", { hasText: "In reply to" }).click();
const replyChain = tile.locator(".mx_ReplyChain:first-of-type");
// Assert that "In reply to" has disappeared
await expect(replyChain.getByText("In reply to")).not.toBeVisible();
// Assert that the file button contains the name of the file sent at first
await expect(
replyChain
.locator(".mx_MFileBody_info[role='button']")
.locator(".mx_MFileBody_info_filename", { hasText: "upload-first.ogg" }),
).toBeVisible();
// Take snapshots
await takeSnapshots(page, app, "Selected EventTile of audio player with a reply chain");
});
test("should be rendered, play, and support replying on a thread", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
// On the main timeline
const messageList = page.locator(".mx_RoomView_MessageList");
// Assert the audio player is rendered
await expect(messageList.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
// Find and click "Reply in thread" button
await messageList.locator(".mx_EventTile_last").hover();
await messageList.locator(".mx_EventTile_last").getByRole("button", { name: "Reply in thread" }).click();
// On a thread
const thread = page.locator(".mx_ThreadView");
const threadTile = thread.locator(".mx_EventTile_last");
const audioPlayer = threadTile.locator(".mx_AudioPlayer_container");
// Assert that the counter is zero before clicking the play button
await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Find and click "Play" button, the wait is to make the test less flaky
await expect(audioPlayer.getByRole("button", { name: "Play" })).toBeVisible();
await audioPlayer.getByRole("button", { name: "Play" }).click();
// Assert that "Pause" button can be found
await expect(audioPlayer.getByRole("button", { name: "Pause" })).toBeVisible();
// Assert that the timer is reset when the audio file finished playing
await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Assert that "Play" button can be found
await expect(audioPlayer.getByRole("button", { name: "Play" })).not.toBeDisabled();
// Find and click "Reply" button
await threadTile.hover();
await threadTile.getByRole("button", { name: "Reply", exact: true }).click();
const composer = thread.locator(".mx_MessageComposer--compact");
// Assert that the reply preview contains audio ReplyTile the file info button
await expect(
composer.locator(".mx_ReplyPreview .mx_ReplyTile_audio .mx_MFileBody_info[role='button']"),
).toBeVisible();
// Select :smile: emoji and send it
await composer.getByTestId("basicmessagecomposer").fill(":smile:");
await composer.locator(".mx_Autocomplete_Completion[aria-selected='true']").click();
await composer.getByTestId("basicmessagecomposer").press("Enter");
// Assert that the file name is rendered on the file button
await expect(threadTile.locator(".mx_ReplyTile_audio .mx_MFileBody_info[role='button']")).toBeVisible();
});
});

View file

@ -0,0 +1,128 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import os from "node:os";
import path from "node:path";
import * as fsp from "node:fs/promises";
import * as fs from "node:fs";
import JSZip from "jszip";
import { test, expect } from "../../element-web-test";
// Based on https://github.com/Stuk/jszip/issues/466#issuecomment-2097061912
async function extractZipFileToPath(file: string, outputPath: string): Promise<JSZip> {
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
const data = await fsp.readFile(file);
const zip = await JSZip.loadAsync(data, { createFolders: true });
await new Promise<void>((resolve, reject) => {
let entryCount = 0;
let errorOut = false;
zip.forEach(() => {
entryCount++;
}); // there is no other way to count the number of entries within the zip file.
zip.forEach((relativePath, zipEntry) => {
if (errorOut) {
return;
}
const outputEntryPath = path.join(outputPath, relativePath);
if (zipEntry.dir) {
if (!fs.existsSync(outputEntryPath)) {
fs.mkdirSync(outputEntryPath, { recursive: true });
}
entryCount--;
if (entryCount === 0) {
resolve();
}
} else {
void zipEntry
.async("blob")
.then(async (content) => Buffer.from(await content.arrayBuffer()))
.then((buffer) => {
const stream = fs.createWriteStream(outputEntryPath);
stream.write(buffer, (error) => {
if (error) {
reject(error);
errorOut = true;
}
});
stream.on("finish", () => {
entryCount--;
if (entryCount === 0) {
resolve();
}
});
stream.end(); // extremely important on Windows. On Mac / Linux, not so much since those platforms allow multiple apps to read from the same file. Windows doesn't allow that.
})
.catch((e) => {
errorOut = true;
reject(e);
});
}
});
});
return zip;
}
test.describe("HTML Export", () => {
test.use({
displayName: "Alice",
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ name: "Important Room" });
await app.viewRoomByName("Important Room");
await use({ roomId });
},
});
test("should export html successfully and match screenshot", 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.
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++) {
await app.client.sendMessage(room.roomId, { body: `Testing ${i}`, msgtype: "m.text" });
}
// Wait for all the messages to be displayed
await expect(
page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText("Testing 9"),
).toBeVisible();
await app.toggleRoomInfoPanel();
await page.getByRole("menuitem", { name: "Export Chat" }).click();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export", exact: true }).click();
const download = await downloadPromise;
const dirPath = path.join(os.tmpdir(), "html-export-test");
const zipPath = `${dirPath}.zip`;
await download.saveAs(zipPath);
const zip = await extractZipFileToPath(zipPath, dirPath);
await page.goto(`file://${dirPath}/${Object.keys(zip.files)[0]}/messages.html`);
await expect(page).toMatchScreenshot("html-export.png", {
mask: [
// We need to mask the whole thing because the width of the time part changes
page.locator(".mx_TimelineSeparator"),
page.locator(".mx_MessageTimestamp"),
],
});
});
});

View file

@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control";
test.describe("Composer", () => {
test.use({
displayName: "Janet",
});
test.use({
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ name: "Composing Room" });
await app.viewRoomByName("Composing Room");
await use({ roomId });
},
});
test.beforeEach(async ({ room }) => {}); // trigger room fixture
test.describe("CIDER", () => {
test("sends a message when you click send or press Enter", async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send a message…" });
// Type a message
await composer.pressSequentially("my message 0");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
// Click send
await page.getByRole("button", { name: "Send message" }).click();
// It has been sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 0" }),
).toBeVisible();
// Type another and press Enter afterward
await composer.pressSequentially("my message 1");
await composer.press("Enter");
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 1" }),
).toBeVisible();
});
test("can write formatted text", async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send a message…" });
await composer.pressSequentially("my bold");
await composer.press(`${CtrlOrMeta}+KeyB`);
await composer.pressSequentially(" message");
await page.getByRole("button", { name: "Send message" }).click();
// Note: both "bold" and "message" are bold, which is probably surprising
await expect(page.locator(".mx_EventTile_body strong", { hasText: "bold message" })).toBeVisible();
});
test("should allow user to input emoji via graphical picker", async ({ page, app }) => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click();
await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker
await page.getByRole("textbox", { name: "Send a message…" }).press("Enter"); // Send message
await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible();
});
test.describe("when Control+Enter is required to send", () => {
test.beforeEach(async ({ app }) => {
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
});
test("only sends when you press Control+Enter", async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send a message…" });
// Type a message and press Enter
await composer.pressSequentially("my message 3");
await composer.press("Enter");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible();
// Press Control+Enter
await composer.press(`${CtrlOrMeta}+Enter`);
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 3" }),
).toBeVisible();
});
});
});
});

View file

@ -0,0 +1,350 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control";
test.describe("Composer", () => {
test.use({
displayName: "Janet",
});
test.use({
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ name: "Composing Room" });
await app.viewRoomByName("Composing Room");
await use({ roomId });
},
});
test.beforeEach(async ({ room }) => {}); // trigger room fixture
test.describe("Rich text editor", () => {
test.use({
labsFlags: ["feature_wysiwyg_composer"],
});
test.describe("Commands", () => {
// TODO add tests for rich text mode
test.describe("Plain text mode", () => {
test("autocomplete behaviour tests", async ({ page }) => {
// Select plain text mode after composer is ready
await expect(page.locator("div[contenteditable=true]")).toBeVisible();
await page.getByRole("button", { name: "Hide formatting" }).click();
// Typing a single / displays the autocomplete menu and contents
await page.getByRole("textbox").press("/");
// Check that the autocomplete options are visible and there are more than 0 items
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Entering `//` or `/ ` hides the autocomplete contents
// Add an extra slash for `//`
await page.getByRole("textbox").press("/");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Remove the extra slash to go back to `/`
await page.getByRole("textbox").press("Backspace");
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Add a trailing space for `/ `
await page.getByRole("textbox").press(" ");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Typing a command that takes no arguments (/devtools) and selecting by click works
await page.getByRole("textbox").press("Backspace");
await page.getByRole("textbox").pressSequentially("dev");
await page.getByTestId("autocomplete-wrapper").getByText("/devtools").click();
// Check it has closed the autocomplete and put the text into the composer
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeVisible();
await expect(page.getByRole("textbox").getByText("/devtools")).toBeVisible();
// Send the message and check the devtools dialog appeared, then close it
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.getByRole("dialog").getByText("Developer Tools")).toBeVisible();
await page.getByRole("button", { name: "Close dialog" }).click();
// Typing a command that takes arguments (/spoiler) and selecting with enter works
await page.getByRole("textbox").pressSequentially("/spoil");
await expect(page.getByTestId("autocomplete-wrapper").getByText("/spoiler")).toBeVisible();
await page.getByRole("textbox").press("Enter");
// Check it has closed the autocomplete and put the text into the composer
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeVisible();
await expect(page.getByRole("textbox").getByText("/spoiler")).toBeVisible();
// Enter some more text, then send the message
await page.getByRole("textbox").pressSequentially("this is the spoiler text ");
await page.getByRole("button", { name: "Send message" }).click();
// Check that a spoiler item has appeared in the timeline and locator the spoiler command text
await expect(page.locator("button.mx_EventTile_spoiler")).toBeVisible();
await expect(page.getByText("this is the spoiler text")).toBeVisible();
});
});
});
test.describe("Mentions", () => {
// TODO add tests for rich text mode
test.describe("Plain text mode", () => {
test.use({
botCreateOpts: {
displayName: "Bob",
},
});
// https://github.com/vector-im/element-web/issues/26037
test.skip("autocomplete behaviour tests", async ({ page, app, bot: bob }) => {
// Set up a private room so we have another user to mention
await app.client.createRoom({
is_direct: true,
invite: [bob.credentials.userId],
});
await app.viewRoomByName("Bob");
// Select plain text mode after composer is ready
await expect(page.locator("div[contenteditable=true]")).toBeVisible();
await page.getByRole("button", { name: "Hide formatting" }).click();
// Typing a single @ does not display the autocomplete menu and contents
await page.getByRole("textbox").press("@");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Entering the first letter of the other user's name opens the autocomplete...
await page.getByRole("textbox").pressSequentially(bob.credentials.displayName.slice(0, 1));
// ...with the other user name visible, and clicking that username...
await page.getByTestId("autocomplete-wrapper").getByText(bob.credentials.displayName).click();
// ...inserts the username into the composer
const pill = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false });
await expect(pill).toHaveAttribute("contenteditable", "false");
await expect(pill).toHaveAttribute("data-mention-type", "user");
// Send the message to clear the composer
await page.getByRole("button", { name: "Send message" }).click();
// Typing an @, then other user's name, then trailing space closes the autocomplete
await page.getByRole("textbox").pressSequentially(`@${bob.credentials.displayName} `);
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Send the message to clear the composer
await page.getByRole("button", { name: "Send message" }).click();
// Moving the cursor back to an "incomplete" mention opens the autocomplete
await page
.getByRole("textbox")
.pressSequentially(`initial text @${bob.credentials.displayName.slice(0, 1)} abc`);
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Move the cursor left by 4 to put it to: `@B| abc`, check autocomplete displays
await page.getByRole("textbox").press("LeftArrow");
await page.getByRole("textbox").press("LeftArrow");
await page.getByRole("textbox").press("LeftArrow");
await page.getByRole("textbox").press("LeftArrow");
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Selecting the autocomplete option using Enter inserts it into the composer
await page.getByRole("textbox").press("Enter");
const pill2 = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false });
await expect(pill2).toHaveAttribute("contenteditable", "false");
await expect(pill2).toHaveAttribute("data-mention-type", "user");
});
});
});
test("sends a message when you click send or press Enter", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
// Click send
await page.getByRole("button", { name: "Send message" }).click();
// It has been sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 0")).toBeVisible();
// Type another
await page.locator("div[contenteditable=true]").pressSequentially("my message 1");
// Send message
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();
});
test("sends only one message when you press Enter multiple times", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
// Click send
await page.locator("div[contenteditable=true]").press("Enter");
await page.locator("div[contenteditable=true]").press("Enter");
await page.locator("div[contenteditable=true]").press("Enter");
// It has been sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 0")).toBeVisible();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body")).toHaveCount(1);
});
test("can write formatted text", async ({ page }) => {
await page.locator("div[contenteditable=true]").pressSequentially("my ");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+KeyB`);
await page.locator("div[contenteditable=true]").pressSequentially("bold");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+KeyB`);
await page.locator("div[contenteditable=true]").pressSequentially(" message");
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_body strong").getByText("bold")).toBeVisible();
});
test.describe("when Control+Enter is required to send", () => {
test.beforeEach(async ({ app }) => {
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
});
test("only sends when you press Control+Enter", async ({ page }) => {
// Type a message and press Enter
await page.locator("div[contenteditable=true]").pressSequentially("my message 3");
await page.locator("div[contenteditable=true]").press("Enter");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible();
// Press Control+Enter
await page.locator("div[contenteditable=true]").press("Control+Enter");
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 3"),
).toBeVisible();
});
});
test.describe("links", () => {
test("create link with a forward selection", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+A`);
// Open link modal
await page.getByRole("button", { name: "Link" }).click();
// Fill the link field
await page.getByRole("textbox", { name: "Link" }).pressSequentially("https://matrix.org/");
// Click on save
await page.getByRole("button", { name: "Save" }).click();
// Send the message
await page.getByRole("button", { name: "Send message" }).click();
// It was sent
await expect(page.locator(".mx_EventTile_body a").getByText("my message 0")).toBeVisible();
await expect(page.locator(".mx_EventTile_body a")).toHaveAttribute(
"href",
new RegExp("https://matrix.org/"),
);
});
});
test.describe("Drafts", () => {
test("drafts with rich and plain text", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Type some formatted text
await composer.pressSequentially("my ");
await composer.press(`${CtrlOrMeta}+KeyB`);
await composer.pressSequentially("bold");
// Change to plain text mode
await page.getByRole("button", { name: "Hide formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// assert the markdown
await expect(page.locator("div[contenteditable=true]", { hasText: "my __bold__" })).toBeVisible();
// Change to plain text mode and assert the markdown
await page.getByRole("button", { name: "Show formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Send the message and assert the message
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my bold")).toBeVisible();
});
test("draft with replies", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Type reply text
await composer.pressSequentially("my reply");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert reply mode and reply text
await expect(page.getByText("Replying")).toBeVisible();
await expect(page.locator("div[contenteditable=true]", { hasText: "my reply" })).toBeVisible();
});
test("draft in threads", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply in thread" }).click();
const thread = page.locator(".mx_ThreadView");
const threadComposer = thread.locator("div[contenteditable=true]");
// Type threaded text
await threadComposer.pressSequentially("my threaded message");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert threaded draft
await expect(
thread.locator("div[contenteditable=true]", { hasText: "my threaded message" }),
).toBeVisible();
});
});
});
});

View file

@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Create Room", () => {
test.use({ displayName: "Jim" });
test("should allow us to create a public room with name, topic & address set", async ({ page, user, app }) => {
const name = "Test room 1";
const topic = "This room is dedicated to this test and this test only!";
const dialog = await app.openCreateRoomDialog();
// Fill name & topic
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
await dialog.getByRole("textbox", { name: "Topic" }).fill(topic);
// Change room to public
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Public room" }).click();
// Fill room address
await dialog.getByRole("textbox", { name: "Room address" }).fill("test-room-1");
// Submit
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(/\/#\/room\/#test-room-1:localhost/);
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
});
});

View file

@ -0,0 +1,112 @@
/*
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 { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
version + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
}
test.describe("Backups", () => {
test.use({
displayName: "Hanako",
});
test("Create, delete and recreate a keys backup", async ({ page, user, app }, workerInfo) => {
// Create a backup
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
// It's the first time and secure storage is not set up, so it will create one
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
// copy the recovery key to use it later
const securityKey = await app.getClipboard();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "1");
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Delete it
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
// Create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Security Key").fill(securityKey);
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// Should be successful
await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "2");
// ==
// Ensure that if you don't have the secret storage passphrase the backup won't be created
// ==
// First delete version 2
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Click "Delete Backup"
await currentDialogLocator.getByTestId("dialog-primary-button").click();
// Try to create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
// But cancel the security key dialog, to simulate not having the secret storage passphrase
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
// check that it failed
await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible();
// cancel
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
// go back to the settings to check that no backup was created (the setup button should still be there)
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible();
});
});

View file

@ -0,0 +1,27 @@
/*
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, expect } from "../../element-web-test";
import { logIntoElement } from "./utils";
test.describe("Complete security", () => {
test.use({
displayName: "Jeff",
});
test("should go straight to the welcome screen if we have no signed device", async ({
page,
homeserver,
credentials,
}) => {
await logIntoElement(page, homeserver, credentials);
await expect(page.getByText("Welcome Jeff", { exact: true })).toBeVisible();
});
// see also "Verify device during login with SAS" in `verifiction.spec.ts`.
});

View file

@ -0,0 +1,248 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022-2024 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 type { Page } from "@playwright/test";
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";
const checkDMRoom = async (page: Page) => {
const body = page.locator(".mx_RoomView_body");
await expect(body.getByText("Alice created this DM.")).toBeVisible();
await expect(body.getByText("Alice invited Bob")).toBeVisible({ timeout: 1000 });
await expect(body.locator(".mx_cryptoEvent").getByText("Encryption enabled")).toBeVisible();
};
const startDMWithBob = async (page: Page, bob: Bot) => {
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click();
await expect(
page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"),
).toBeVisible();
await page.getByRole("button", { name: "Go" }).click();
};
const testMessages = async (page: Page, bob: Bot, bobRoomId: string) => {
// check the invite message
await expect(
page.locator(".mx_EventTile", { hasText: "Hey!" }).locator(".mx_EventTile_e2eIcon_warning"),
).not.toBeVisible();
// Bob sends a response
await bob.sendMessage(bobRoomId, "Hoo!");
await expect(
page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"),
).not.toBeVisible();
};
const bobJoin = async (page: Page, bob: Bot) => {
// Wait for Bob to get the invite
await bob.evaluate(async (cli) => {
const bobRooms = cli.getRooms();
if (!bobRooms.length) {
await new Promise<void>((resolve) => {
const onMembership = (_event) => {
cli.off(window.matrixcs.RoomMemberEvent.Membership, onMembership);
resolve();
};
cli.on(window.matrixcs.RoomMemberEvent.Membership, onMembership);
});
}
});
const roomId = await bob.joinRoomByName("Alice");
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Even though Alice has seen Bob's join event, Bob may not have done so yet. Wait for the sync to arrive.
await bob.awaitRoomMembership(roomId);
return roomId;
};
test.describe("Cryptography", function () {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "Bob",
autoAcceptInvites: false,
},
});
for (const isDeviceVerified of [true, false]) {
test.describe(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => {
/**
* Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server
* @param keyType
*/
async function verifyKey(app: ElementAppPage, keyType: string) {
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
keyType,
);
expect(accountData.encrypted).toBeDefined();
const keys = Object.keys(accountData.encrypted);
const key = accountData.encrypted[keys[0]];
expect(key.ciphertext).toBeDefined();
expect(key.iv).toBeDefined();
expect(key.mac).toBeDefined();
}
test("by recovery code", async ({ page, app, user: aliceCredentials }) => {
// Verified the device
if (isDeviceVerified) {
await app.client.bootstrapCrossSigning(aliceCredentials);
}
await page.route("**/_matrix/client/v3/keys/signatures/upload", async (route) => {
// We delay this API otherwise the `Setting up keys` may happen too quickly and cause flakiness
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
});
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = page.locator(".mx_Dialog");
// Recovery key is selected by default
await dialog.getByRole("button", { name: "Continue" }).click();
await copyAndContinue(page);
// When the device is verified, the `Setting up keys` step is skipped
if (!isDeviceVerified) {
const uiaDialogTitle = page.locator(".mx_InteractiveAuthDialog .mx_Dialog_title");
await expect(uiaDialogTitle.getByText("Setting up keys")).toBeVisible();
await expect(uiaDialogTitle.getByText("Setting up keys")).not.toBeVisible();
}
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
test("by passphrase", async ({ page, app, user: aliceCredentials }) => {
// Verified the device
if (isDeviceVerified) {
await app.client.bootstrapCrossSigning(aliceCredentials);
}
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = page.locator(".mx_Dialog");
// Select passphrase option
await dialog.getByText("Enter a Security Phrase").click();
await dialog.getByRole("button", { name: "Continue" }).click();
// Fill passphrase input
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
// Confirm passphrase
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await copyAndContinue(page);
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
});
}
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
const secretStorageKey = await enableKeyBackup(app);
// Fetch the current cross-signing keys
async function fetchMasterKey() {
return await test.step("Fetch master key from server", async () => {
const k = await app.client.evaluate(async (cli) => {
const userId = cli.getUserId();
const keys = await cli.downloadKeysForUsers([userId]);
return Object.values(keys.master_keys[userId].keys)[0];
});
console.log(`fetchMasterKey: ${k}`);
return k;
});
}
const masterKey1 = await fetchMasterKey();
// Find the "reset cross signing" button, and click it
await app.settings.openUserSettings("Security & Privacy");
await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click();
// Confirm
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
// Enter the 4S key
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
await page.getByRole("button", { name: "Continue" }).click();
// Enter the password
await page.getByPlaceholder("Password").fill(aliceCredentials.password);
await page.getByRole("button", { name: "Continue" }).click();
await expect(async () => {
const masterKey2 = await fetchMasterKey();
expect(masterKey1).not.toEqual(masterKey2);
}).toPass();
// The dialog should have gone away
await expect(page.locator(".mx_Dialog")).toHaveCount(1);
});
test("creating a DM should work, being e2e-encrypted / user verification", async ({
page,
app,
bot: bob,
user: aliceCredentials,
}) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await startDMWithBob(page, bob);
// send first message
await page.getByRole("textbox", { name: "Send a message…" }).fill("Hey!");
await page.getByRole("textbox", { name: "Send a message…" }).press("Enter");
await checkDMRoom(page);
const bobRoomId = await bobJoin(page, bob);
await testMessages(page, bob, bobRoomId);
await verify(app, bob);
// Assert that verified icon is rendered
await page.getByTestId("base-card-back-button").click();
await page.getByLabel("Room info").nth(1).click();
await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="green"]')).toContainText("Encrypted");
// Take a snapshot of RoomSummaryCard with a verified E2EE icon
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png");
});
test("should allow verification when there is no existing DM", async ({
page,
app,
bot: bob,
user: aliceCredentials,
}) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await autoJoin(bob);
// we need to have a room with the other user present, so we can open the verification panel
await createSharedRoomWithUser(app, bob.credentials.userId);
await verify(app, bob);
});
});

View file

@ -0,0 +1,294 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022-2024 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 type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix";
import { expect, test } from "../../element-web-test";
import {
createRoom,
enableKeyBackup,
logIntoElement,
logOutOfElement,
sendMessageInCurrentRoom,
verifySession,
} from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Cryptography", function () {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "Bob",
autoAcceptInvites: false,
},
});
test.describe("decryption failure messages", () => {
test("should handle device-relative historical messages", async ({
homeserver,
page,
app,
credentials,
user,
}) => {
test.setTimeout(60000);
// Start with a logged-in session, without key backup, and send a message.
await createRoom(page, "Test room", true);
await sendMessageInCurrentRoom(page, "test test");
// Log out, discarding the key for the sent message.
await logOutOfElement(page, true);
// Log in again, and see how the message looks.
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");
await expect(lastTile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
// Now, we set up key backup, and then send another message.
const secretStorageKey = await enableKeyBackup(app);
await app.viewRoomByName("Test room");
await sendMessageInCurrentRoom(page, "test2 test2");
// Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for
// the key to be backed up.
await page.waitForTimeout(10000);
// Finally, log out again, and back in, skipping verification for now, and see what we see.
await logOutOfElement(page);
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");
// There should be two historical events in the timeline
const tiles = await page.locator(".mx_EventTile").all();
expect(tiles.length).toBeGreaterThanOrEqual(2);
// look at the last two tiles only
for (const tile of tiles.slice(-2)) {
await expect(tile).toContainText("You need to verify this device for access to historical messages");
await expect(tile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
}
// Now verify our device (setting up key backup), and check what happens
await verifySession(app, secretStorageKey);
const tilesAfterVerify = (await page.locator(".mx_EventTile").all()).slice(-2);
// The first message still cannot be decrypted, because it was never backed up. It's now a regular UTD though.
await expect(tilesAfterVerify[0]).toContainText("Unable to decrypt message");
await expect(tilesAfterVerify[0].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
// The second message should now be decrypted, with a grey shield
await expect(tilesAfterVerify[1]).toContainText("test2 test2");
await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible();
});
test.describe("non-joined historical messages", () => {
test.skip(isDendrite, "does not yet support membership on events");
test("should display undecryptable non-joined historical messages with a different message", async ({
homeserver,
page,
app,
credentials: aliceCredentials,
user: alice,
bot: bob,
}) => {
// Bob creates an encrypted room and sends a message to it. He then invites Alice
const roomId = await bob.evaluate(
async (client, { alice }) => {
const encryptionStatePromise = new Promise<void>((resolve) => {
client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => {
if (event.getType() === "m.room.encryption") {
resolve();
}
});
});
const { room_id: roomId } = await client.createRoom({
initial_state: [
{
type: "m.room.encryption",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
name: "Test room",
preset: "private_chat" as Preset,
});
// wait for m.room.encryption event, so that when we send a
// message, it will be encrypted
await encryptionStatePromise;
await client.sendTextMessage(roomId, "This should be undecryptable");
await client.invite(roomId, alice.userId);
return roomId;
},
{ alice },
);
// Alice accepts the invite
await expect(
page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
).toHaveCount(1);
await page.getByRole("treeitem", { name: "Test room" }).click();
await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
// Bob sends an encrypted event and an undecryptable event
await bob.evaluate(
async (client, { roomId }) => {
await client.sendTextMessage(roomId, "This should be decryptable");
await client.sendEvent(
roomId,
"m.room.encrypted" as any,
{
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: "this+message+will+be+undecryptable",
device_id: client.getDeviceId()!,
sender_key: (await client.getCrypto()!.getOwnDeviceKeys()).ed25519,
session_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
} as any,
);
},
{ roomId },
);
// We wait for the event tiles that we expect from the messages that
// Bob sent, in sequence.
await expect(
page.locator(`.mx_EventTile`).getByText("You don't have access to this message"),
).toBeVisible();
await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible();
await expect(page.locator(`.mx_EventTile`).getByText("Unable to decrypt message")).toBeVisible();
// And then we ensure that they are where we expect them to be
// Alice should see these event tiles:
// - first message sent by Bob (undecryptable)
// - Bob invited Alice
// - Alice joined the room
// - second message sent by Bob (decryptable)
// - third message sent by Bob (undecryptable)
const tiles = await page.locator(".mx_EventTile").all();
expect(tiles.length).toBeGreaterThanOrEqual(5);
// The first message from Bob was sent before Alice was in the room, so should
// be different from the standard UTD message
await expect(tiles[tiles.length - 5]).toContainText("You don't have access to this message");
await expect(tiles[tiles.length - 5].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
// The second message from Bob should be decryptable
await expect(tiles[tiles.length - 2]).toContainText("This should be decryptable");
// this tile won't have an e2e icon since we got the key from the sender
// The third message from Bob is undecryptable, but was sent while Alice was
// in the room and is expected to be decryptable, so this should have the
// standard UTD message
await expect(tiles[tiles.length - 1]).toContainText("Unable to decrypt message");
await expect(tiles[tiles.length - 1].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
});
test("should be able to jump to a message sent before our last join event", async ({
homeserver,
page,
app,
credentials: aliceCredentials,
user: alice,
bot: bob,
}) => {
// Bob:
// - creates an encrypted room,
// - invites Alice,
// - sends a message to it,
// - kicks Alice,
// - sends a bunch more events
// - invites Alice again
// In this way, there will be an event that Alice can decrypt,
// followed by a bunch of undecryptable events which Alice shouldn't
// expect to be able to decrypt. The old code would have hidden all
// the events, even the decryptable event (which it wouldn't have
// even tried to fetch, if it was far enough back).
const { roomId, eventId } = await bob.evaluate(
async (client, { alice }) => {
const { room_id: roomId } = await client.createRoom({
initial_state: [
{
type: "m.room.encryption",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
name: "Test room",
preset: "private_chat" as Preset,
});
// invite Alice
const inviteAlicePromise = new Promise<void>((resolve) => {
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "invite") {
resolve();
}
});
});
await client.invite(roomId, alice.userId);
// wait for the invite to come back so that we encrypt to Alice
await inviteAlicePromise;
// send a message that Alice should be able to decrypt
const { event_id: eventId } = await client.sendTextMessage(
roomId,
"This should be decryptable",
);
// kick Alice
const kickAlicePromise = new Promise<void>((resolve) => {
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "leave") {
resolve();
}
});
});
await client.kick(roomId, alice.userId);
await kickAlicePromise;
// send a bunch of messages that Alice won't be able to decrypt
for (let i = 0; i < 20; i++) {
await client.sendTextMessage(roomId, `${i}`);
}
// invite Alice again
await client.invite(roomId, alice.userId);
return { roomId, eventId };
},
{ alice },
);
// Alice accepts the invite
await expect(
page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
).toHaveCount(1);
await page.getByRole("treeitem", { name: "Test room" }).click();
await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
// wait until we're joined and see the timeline
await expect(page.locator(`.mx_EventTile`).getByText("Alice joined the room")).toBeVisible();
// we should be able to jump to the decryptable message that Bob sent
await page.goto(`#/room/${roomId}/${eventId}`);
await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible();
});
});
});
});

View file

@ -0,0 +1,106 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Locator, type Page } from "@playwright/test";
import { test as base, expect } from "../../element-web-test";
import { viewRoomSummaryByName } from "../right-panel/utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const test = base.extend({
// 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";
function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_EntityTile, [title="${name}"]`);
}
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) => {
test.skip(workerInfo.project.name === "Legacy Crypto", "This test only works with Rust crypto.");
// Create a backup (which will create SSSS, and dehydrated device)
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
// It's the first time and secure storage is not set up, so it will create one
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
// The Security tab should indicate that there is a dehydrated device present
await expect(securityTab.getByText("Offline device enabled")).toBeVisible();
await app.settings.closeDialog();
// the dehydrated device gets created with the name "Dehydrated
// device". We want to make sure that it is not visible as a normal
// device.
const sessionsTab = await app.settings.openUserSettings("Sessions");
await expect(sessionsTab.getByText("Dehydrated device")).not.toBeVisible();
await app.settings.closeDialog();
// now check that the user info right-panel shows the dehydrated device
// as a feature rather than as a normal device
await app.client.createRoom({ name: ROOM_NAME });
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click();
await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible();
await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible();
});
});

View file

@ -0,0 +1,259 @@
/*
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 jsQR from "jsqr";
import type { JSHandle, Locator, Page } from "@playwright/test";
import type { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { test, expect } from "../../element-web-test";
import {
awaitVerifier,
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
doTwoWaySasVerification,
logIntoElement,
waitForVerificationRequest,
} from "./utils";
import { Bot } from "../../pages/bot";
test.describe("Device verification", () => {
let aliceBotClient: Bot;
/** The backup version that was set up by the bot client. */
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => {
// 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.
async function initiateAliceVerificationRequest(page: Page): Promise<JSHandle<VerificationRequest>> {
// alice bot waits for verification request
const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient);
// Click on "Verify with another device"
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with another device" }).click();
// alice bot responds yes to verification request from alice
return promiseVerificationRequest;
}
test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, homeserver, credentials);
// Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page);
// Handle emoji SAS verification
const infoDialog = page.locator(".mx_InfoDialog");
// the bot chooses to do an emoji verification
const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
// Handle emoji request and check that emojis are matching
await doTwoWaySasVerification(page, verifier);
await infoDialog.getByRole("button", { name: "They match" }).click();
await infoDialog.getByRole("button", { name: "Got it" }).click();
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// For now we don't check that the backup key is in cache because it's a bit flaky,
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
});
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, homeserver, credentials);
// Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page);
const infoDialog = page.locator(".mx_InfoDialog");
// feed the QR code into the verification request.
const qrData = await readQrCode(infoDialog);
const verifier = await verificationRequest.evaluateHandle(
(request, qrData) => request.scanQRCode(new Uint8Array(qrData)),
[...qrData],
);
// Confirm that the bot user scanned successfully
await expect(infoDialog.getByText("Almost there! Is your other device showing the same shield?")).toBeVisible();
await infoDialog.getByRole("button", { name: "Yes" }).click();
await infoDialog.getByRole("button", { name: "Got it" }).click();
// wait for the bot to see we have finished
await verifier.evaluate((verifier) => verifier.verify());
// the bot uploads the signatures asynchronously, so wait for that to happen
await page.waitForTimeout(1000);
// our device should trust the bot device
await app.client.evaluate(async (cli, aliceBotCredentials) => {
const deviceStatus = await cli
.getCrypto()!
.getDeviceVerificationStatus(aliceBotCredentials.userId, aliceBotCredentials.deviceId);
if (!deviceStatus.isVerified()) {
throw new Error("Bot device was not verified after QR code verification");
}
}, aliceBotClient.credentials);
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// For now we don't check that the backup key is in cache because it's a bit flaky,
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
});
test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, homeserver, credentials);
// Select the security phrase
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
// Fill the passphrase
const dialog = page.locator(".mx_Dialog");
await dialog.locator("input").fill("new passphrase");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
// 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 checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
});
test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, homeserver, credentials);
// Select the security phrase
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
// Fill the security key
const dialog = page.locator(".mx_Dialog");
await dialog.getByRole("button", { name: "use your Security Key" }).click();
const aliceRecoveryKey = await aliceBotClient.getRecoveryKey();
await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey);
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
// 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 checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
});
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
await logIntoElement(page, homeserver, credentials);
/* Dismiss "Verify this device" */
const authPage = page.locator(".mx_AuthPage");
await authPage.getByRole("button", { name: "Skip verification for now" }).click();
await authPage.getByRole("button", { name: "I'll verify later" }).click();
await page.waitForSelector(".mx_MatrixChat");
const elementDeviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId());
/* Now initiate a verification request from the *bot* device. */
const botVerificationRequest = await aliceBotClient.evaluateHandle(
async (client, { userId, deviceId }) => {
return client.getCrypto()!.requestDeviceVerification(userId, deviceId);
},
{ userId: credentials.userId, deviceId: elementDeviceId },
);
/* Check the toast for the incoming request */
const toast = await toasts.getToast("Verification requested");
// it should contain the device ID of the requesting device
await expect(toast.getByText(`${aliceBotClient.credentials.deviceId} from `)).toBeVisible();
// Accept
await toast.getByRole("button", { name: "Verify Session" }).click();
/* Click 'Start' to start SAS verification */
await page.getByRole("button", { name: "Start" }).click();
/* on the bot side, wait for the verifier to exist ... */
const verifier = await awaitVerifier(botVerificationRequest);
// ... confirm ...
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();
await expect(
infoDialog.getByText(`You've successfully verified (${aliceBotClient.credentials.deviceId})!`),
).toBeVisible();
await infoDialog.getByRole("button", { name: "Got it" }).click();
});
});
/** Extract the qrcode out of an on-screen html element */
async function readQrCode(base: Locator) {
const qrCode = base.locator('[alt="QR Code"]');
const imageData = await qrCode.evaluate<
{
colorSpace: PredefinedColorSpace;
width: number;
height: number;
buffer: number[];
},
HTMLImageElement
>(async (img) => {
// draw the image on a canvas
const myCanvas = new OffscreenCanvas(img.width, img.height);
const ctx = myCanvas.getContext("2d");
ctx.drawImage(img, 0, 0);
// read the image data
const imageData = ctx.getImageData(0, 0, myCanvas.width, myCanvas.height);
return {
colorSpace: imageData.colorSpace,
width: imageData.width,
height: imageData.height,
buffer: [...new Uint8ClampedArray(imageData.data.buffer)],
};
});
// now we can decode the QR code.
const result = jsQR(new Uint8ClampedArray(imageData.buffer), imageData.width, imageData.height);
return new Uint8Array(result.binaryData);
}

View file

@ -0,0 +1,311 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022-2024 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 {
autoJoin,
createSecondBotDevice,
createSharedRoomWithUser,
enableKeyBackup,
logIntoElement,
logOutOfElement,
verify,
} from "./utils";
test.describe("Cryptography", function () {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "Bob",
autoAcceptInvites: false,
},
});
test.describe("event shields", () => {
let testRoomId: string;
test.beforeEach(async ({ page, bot: bob, user: aliceCredentials, app }) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await autoJoin(bob);
// create an encrypted room, and wait for Bob to join it.
testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, {
name: "TestRoom",
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
});
// Even though Alice has seen Bob's join event, Bob may not have done so yet. Wait for the sync to arrive.
await bob.awaitRoomMembership(testRoomId);
});
test("should show the correct shield on e2e events", async ({
page,
app,
bot: bob,
homeserver,
}, workerInfo) => {
// Bob has a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: "the bird is in the hand",
});
const last = page.locator(".mx_EventTile_last");
await expect(last).toContainText("Unable to decrypt message");
const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon");
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/);
await lastE2eIcon.focus();
await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText(
"This message could not be decrypted",
);
/* Should show a red padlock for an unencrypted message in an e2e room */
await bob.evaluate(
(cli, testRoomId) =>
cli.http.authedRequest(
window.matrixcs.Method.Put,
`/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`,
undefined,
{
msgtype: "m.text",
body: "test unencrypted",
},
),
testRoomId,
);
await expect(last).toContainText("test unencrypted");
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastE2eIcon.focus();
await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText("Not encrypted");
/* Should show no padlock for an unverified user */
// bob sends a valid event
await bob.sendMessage(testRoomId, "test encrypted 1");
// the message should appear, decrypted, with no warning, but also no "verified"
const lastTile = page.locator(".mx_EventTile_last");
const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon");
await expect(lastTile).toContainText("test encrypted 1");
// no e2e icon
await expect(lastTileE2eIcon).not.toBeVisible();
/* Now verify Bob */
await verify(app, bob);
/* Existing message should be updated when user is verified. */
await expect(last).toContainText("test encrypted 1");
// still no e2e icon
await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible();
/* should show no padlock, and be verified, for a message from a verified device */
await bob.sendMessage(testRoomId, "test encrypted 2");
await expect(lastTile).toContainText("test encrypted 2");
// no e2e icon
await expect(lastTileE2eIcon).not.toBeVisible();
/* should show red padlock for a message from an unverified device */
await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified");
await expect(lastTile).toContainText("test encrypted from unverified");
await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastTileE2eIcon.focus();
await expect(await app.getTooltipForElement(lastTileE2eIcon)).toContainText(
"Encrypted by a device not verified by its owner.",
);
/* In legacy crypto: should show a grey padlock for a message from a deleted device.
* In rust crypto: should show a red padlock for a message from an unverified device.
* Rust crypto remembers the verification state of the sending device, so it will know that the device was
* unverified, even if it gets deleted. */
// bob deletes his second device
await bobSecondDevice.evaluate((cli) => cli.logout(true));
// wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info.
async function awaitOneDevice(iterations = 1) {
const rightPanel = page.locator(".mx_RightPanel");
await rightPanel.getByTestId("base-card-back-button").click();
await rightPanel.getByText("Bob").click();
const sessionCountText = await rightPanel
.locator(".mx_UserInfo_devices")
.getByText(" session", { exact: false })
.textContent();
// cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here
if (sessionCountText != "1 session" && sessionCountText != "1 verified session") {
if (iterations >= 10) {
throw new Error(`Bob still has ${sessionCountText} after 10 iterations`);
}
await awaitOneDevice(iterations + 1);
}
}
await awaitOneDevice();
// close and reopen the room, to get the shield to update.
await app.viewRoomByName("Bob");
await app.viewRoomByName("TestRoom");
await expect(last).toContainText("test encrypted from unverified");
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastE2eIcon.focus();
await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText(
workerInfo.project.name === "Legacy Crypto"
? "Encrypted by an unknown or deleted device."
: "Encrypted by a device not verified by its owner.",
);
});
test("Should show a grey padlock for a key restored from backup", async ({
page,
app,
bot: bob,
homeserver,
user: aliceCredentials,
}) => {
test.slow();
const securityKey = await enableKeyBackup(app);
// bob sends a valid event
await bob.sendMessage(testRoomId, "test encrypted 1");
const lastTile = page.locator(".mx_EventTile_last");
const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon");
await expect(lastTile).toContainText("test encrypted 1");
// no e2e icon
await expect(lastTileE2eIcon).not.toBeVisible();
// Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for
// the key to be backed up.
await page.waitForTimeout(10000);
/* log out, and back in */
await logOutOfElement(page);
// Reload to work around a Rust crypto bug where it can hold onto the indexeddb even after logout
// https://github.com/element-hq/element-web/issues/25779
await page.addInitScript(() => {
// When we reload, the initScript created by the `user`/`pageWithCredentials` fixtures
// will re-inject the original credentials into localStorage, which we don't want.
// To work around, we add a second initScript which will clear localStorage again.
window.localStorage.clear();
});
await page.reload();
await logIntoElement(page, homeserver, aliceCredentials, securityKey);
/* go back to the test room and find Bob's message again */
await app.viewRoomById(testRoomId);
await expect(lastTile).toContainText("test encrypted 1");
// The gray shield would be a mx_EventTile_e2eIcon_normal. The red shield would be a mx_EventTile_e2eIcon_warning.
// No shield would have no div mx_EventTile_e2eIcon at all.
await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_normal/);
await lastTileE2eIcon.hover();
// The key is coming from backup, so it is not anymore possible to establish if the claimed device
// creator of this key is authentic. The tooltip should be "The authenticity of this encrypted message can't be guaranteed on this device."
// It is not "Encrypted by an unknown or deleted device." even if the claimed device is actually deleted.
await expect(await app.getTooltipForElement(lastTileE2eIcon)).toContainText(
"The authenticity of this encrypted message can't be guaranteed on this device.",
);
});
test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => {
// bob has a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
// verify Bob
await verify(app, bob);
// bob sends a valid event
const testEvent = await bob.sendMessage(testRoomId, "Hoo!");
// the message should appear, decrypted, with no warning
await expect(
page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"),
).not.toBeVisible();
// bob sends an edit to the first message with his unverified device
await bobSecondDevice.sendMessage(testRoomId, {
"m.new_content": {
msgtype: "m.text",
body: "Haa!",
},
"m.relates_to": {
rel_type: "m.replace",
event_id: testEvent.event_id,
},
});
// the edit should have a warning
await expect(
page.locator(".mx_EventTile", { hasText: "Haa!" }).locator(".mx_EventTile_e2eIcon_warning"),
).toBeVisible();
// a second edit from the verified device should be ok
await bob.sendMessage(testRoomId, {
"m.new_content": {
msgtype: "m.text",
body: "Hee!",
},
"m.relates_to": {
rel_type: "m.replace",
event_id: testEvent.event_id,
},
});
await expect(
page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"),
).not.toBeVisible();
});
test("should show correct shields on events sent by devices which have since been deleted", async ({
page,
app,
bot: bob,
homeserver,
}) => {
// Our app is blocked from syncing while Bob sends his messages.
await app.client.network.goOffline();
// Bob sends a message from his verified device
await bob.sendMessage(testRoomId, "test encrypted from verified");
// And one from a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
await bobSecondDevice.waitForNextSync(); // make sure the client knows the room is encrypted
await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified");
// ... and then logs out both devices.
await bob.evaluate((cli) => cli.logout(true));
await bobSecondDevice.evaluate((cli) => cli.logout(true));
// Let our app start syncing again
await app.client.network.goOnline();
// Wait for the messages to arrive. It can take quite a while for the sync to wake up.
const last = page.locator(".mx_EventTile_last");
await expect(last).toContainText("test encrypted from unverified", { timeout: 20000 });
const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon");
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastE2eIcon.focus();
await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText(
"Encrypted by a device not verified by its owner.",
);
const penultimate = page.locator(".mx_EventTile").filter({ hasText: "test encrypted from verified" });
await expect(penultimate.locator(".mx_EventTile_e2eIcon")).not.toBeVisible();
});
});
});

View file

@ -0,0 +1,56 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import { autoJoin, createSecondBotDevice, createSharedRoomWithUser, verify } from "./utils";
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.use({
displayName: "Alice",
botCreateOpts: { displayName: "Bob" },
labsFlags: ["feature_exclude_insecure_devices"],
});
test("Messages fail to decrypt when sender is previously verified", async ({
page,
bot: bob,
user: aliceCredentials,
app,
homeserver,
}) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await autoJoin(bob);
// create an encrypted room
const testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, {
name: "TestRoom",
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
});
// Verify Bob
await verify(app, bob);
// Bob logs in a new device and resets cross-signing
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials, true);
/* should show an error for a message from a previously verified device */
await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified");
const lastTile = page.locator(".mx_EventTile_last");
await expect(lastTile).toContainText("Verified identity has changed");
});
});

View file

@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
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";
test.describe("Logout tests", () => {
test.beforeEach(async ({ page, homeserver, credentials }) => {
await logIntoElement(page, homeserver, credentials);
});
test("Ask to set up recovery on logout if not setup", async ({ page, app }) => {
await createRoom(page, "E2e room", true);
// send a message (will be the first one so will create a new megolm session)
await sendMessageInCurrentRoom(page, "Hello secret world");
const locator = await app.settings.openUserMenu();
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
await expect(
currentDialogLocator.getByRole("heading", { name: "You'll lose access to your encrypted messages" }),
).toBeVisible();
});
test("If backup is set up show standard confirm", async ({ page, app }) => {
await enableKeyBackup(app);
await createRoom(page, "E2e room", true);
// send a message (will be the first one so will create a new megolm session)
await sendMessageInCurrentRoom(page, "Hello secret world");
const locator = await app.settings.openUserMenu();
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
await expect(currentDialogLocator.getByText("Are you sure you want to sign out?")).toBeVisible();
});
test("Logout directly if the user has no room keys", async ({ page, app }) => {
await createRoom(page, "Clear room", false);
await sendMessageInCurrentRoom(page, "Hello public world!");
const locator = await app.settings.openUserMenu();
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
// Should have logged out directly
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
});
});

View file

@ -0,0 +1,64 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023, 2024 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 path from "path";
import { readFile } from "node:fs/promises";
import { expect, test as base } from "../../element-web-test";
const test = base.extend({
// 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", function () {
test.use({ displayName: "Alice" });
test("Should support migration from legacy crypto", async ({ context, user, page }, workerInfo) => {
test.skip(workerInfo.project.name === "Legacy Crypto", "This test only works with Rust crypto.");
test.slow();
// We should see a migration progress bar
await page.getByText("Hang tight.").waitFor({ timeout: 60000 });
// When the progress bar first loads, it should have a high max (one per megolm session to import), and
// a relatively low value.
const progressBar = page.getByRole("progressbar");
const initialProgress = parseFloat(await progressBar.getAttribute("value"));
const initialMax = parseFloat(await progressBar.getAttribute("max"));
expect(initialMax).toBeGreaterThan(4000);
expect(initialProgress).toBeGreaterThanOrEqual(0);
expect(initialProgress).toBeLessThanOrEqual(500);
// Later, the progress should pass 50%
await expect
.poll(
async () => {
const progressBar = page.getByRole("progressbar");
return (
(parseFloat(await progressBar.getAttribute("value")) * 100.0) /
parseFloat(await progressBar.getAttribute("max"))
);
},
{ timeout: 60000 },
)
.toBeGreaterThan(50);
// Eventually, we should get a normal matrix chat
await page.waitForSelector(".mx_MatrixChat", { timeout: 120000 });
});
});

View file

@ -0,0 +1,60 @@
# Dump of libolm indexeddb cryptostore
This directory contains, in `dump.json`, a dump of a real indexeddb store from a session using
libolm crypto.
The corresponding pickle key is `+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o`.
This directory also contains, in `index.html` and `load.js`, a page which will populate indexeddb with the data
(and the pickle key). This can be served via a Playwright [Route](https://playwright.dev/docs/api/class-route) so as to
populate the indexeddb before the main application loads. Note that encrypting the pickle key requires the test User ID
and Device ID, so they must be stored in `localstorage` before loading `index.html`.
## Creation of the dump file
The dump was created by pasting the following into the browser console:
```javascript
async function exportIndexedDb(name) {
const db = await new Promise((resolve, reject) => {
const dbReq = indexedDB.open(name);
dbReq.onerror = reject;
dbReq.onsuccess = () => resolve(dbReq.result);
});
const storeNames = db.objectStoreNames;
const exports = {};
for (const store of storeNames) {
exports[store] = [];
const txn = db.transaction(store, "readonly");
const objectStore = txn.objectStore(store);
await new Promise((resolve, reject) => {
const cursorReq = objectStore.openCursor();
cursorReq.onerror = reject;
cursorReq.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const entry = { value: cursor.value };
if (!objectStore.keyPath) {
entry.key = cursor.key;
}
exports[store].push(entry);
cursor.continue();
} else {
resolve();
}
};
});
}
return exports;
}
window.saveAs(
new Blob([JSON.stringify(await exportIndexedDb("matrix-js-sdk:crypto"), null, 2)], {
type: "application/json;charset=utf-8",
}),
"dump.json",
);
```
The pickle key is extracted via `mxMatrixClientPeg.get().crypto.olmDevice.pickleKey`.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,6 @@
<html>
<head>
<script src="load.js"></script>
</head>
Loading test data...
</html>

View file

@ -0,0 +1,220 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023, 2024 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.
*/
/* Browser-side javascript to fetch the indexeddb dump file, and populate indexeddb. */
/** The pickle key corresponding to the data dump. */
const PICKLE_KEY = "+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o";
/**
* Populate an IndexedDB store with the test data from this directory.
*
* @param {any} data - IndexedDB dump to import
* @param {string} name - Name of the IndexedDB database to create.
*/
async function populateStore(data, name) {
const req = indexedDB.open(name, 11);
const db = await new Promise((resolve, reject) => {
req.onupgradeneeded = (ev) => {
const db = req.result;
const oldVersion = ev.oldVersion;
upgradeDatabase(oldVersion, db);
};
req.onerror = (ev) => {
reject(req.error);
};
req.onsuccess = () => {
const db = req.result;
resolve(db);
};
});
await importData(data, db);
return db;
}
/**
* Create the schema for the indexed db store
*
* @param {number} oldVersion - The current version of the store.
* @param {IDBDatabase} db - The indexeddb database.
*/
function upgradeDatabase(oldVersion, db) {
if (oldVersion < 1) {
const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" });
outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]);
outgoingRoomKeyRequestsStore.createIndex("state", "state");
}
if (oldVersion < 2) {
db.createObjectStore("account");
}
if (oldVersion < 3) {
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"] });
sessionsStore.createIndex("deviceKey", "deviceKey");
}
if (oldVersion < 4) {
db.createObjectStore("inbound_group_sessions", { keyPath: ["senderCurve25519Key", "sessionId"] });
}
if (oldVersion < 5) {
db.createObjectStore("device_data");
}
if (oldVersion < 6) {
db.createObjectStore("rooms");
}
if (oldVersion < 7) {
db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"] });
}
if (oldVersion < 8) {
db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"] });
}
if (oldVersion < 9) {
const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"] });
problemsStore.createIndex("deviceKey", "deviceKey");
db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"] });
}
if (oldVersion < 10) {
db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"] });
}
if (oldVersion < 11) {
db.createObjectStore("parked_shared_history", { keyPath: ["roomId"] });
}
}
/** Do the import of data into the database
*
* @param {any} json - The data to import.
* @param {IDBDatabase} db - The database to import into.
* @returns {Promise<void>}
*/
async function importData(json, db) {
for (const [storeName, data] of Object.entries(json)) {
await new Promise((resolve, reject) => {
console.log(`Populating ${storeName} with test data`);
const store = db.transaction(storeName, "readwrite").objectStore(storeName);
function putEntry(idx) {
if (idx >= data.length) {
resolve(undefined);
return;
}
const { key, value } = data[idx];
try {
const putReq = store.put(value, key);
putReq.onsuccess = (_) => putEntry(idx + 1);
putReq.onerror = (_) => reject(putReq.error);
} catch (e) {
throw new Error(
`Error populating '${storeName}' with key ${JSON.stringify(key)}, value ${JSON.stringify(
value,
)}: ${e}`,
);
}
}
putEntry(0);
});
}
}
function getPickleAdditionalData(userId, deviceId) {
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
return additionalData;
}
/** Save an entry to the `matrix-react-sdk` indexeddb database.
*
* If `matrix-react-sdk` does not yet exist, it will be created with the correct schema.
*
* @param {String} table
* @param {String} key
* @param {String} data
* @returns {Promise<void>}
*/
async function idbSave(table, key, data) {
const idb = await new Promise((resolve, reject) => {
const request = indexedDB.open("matrix-react-sdk", 1);
request.onerror = reject;
request.onsuccess = () => {
resolve(request.result);
};
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore("pickleKey");
db.createObjectStore("account");
};
});
return await new Promise((resolve, reject) => {
const txn = idb.transaction([table], "readwrite");
txn.onerror = reject;
const objectStore = txn.objectStore(table);
const request = objectStore.put(data, key);
request.onerror = reject;
request.onsuccess = resolve;
});
}
/**
* Save the pickle key to indexeddb, so that the app can read it.
*
* @param {String} userId - The user's ID (used in the encryption algorithm).
* @param {String} deviceId - The user's device ID (ditto).
* @returns {Promise<void>}
*/
async function savePickleKey(userId, deviceId) {
const itFunc = function* () {
const decoded = atob(PICKLE_KEY);
for (let i = 0; i < decoded.length; ++i) {
yield decoded.charCodeAt(i);
}
};
const decoded = Uint8Array.from(itFunc());
const cryptoKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const iv = new Uint8Array(32);
crypto.getRandomValues(iv);
const additionalData = getPickleAdditionalData(userId, deviceId);
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, cryptoKey, decoded);
await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey });
}
async function loadDump() {
const dump = await fetch("dump.json");
const indexedDbDump = await dump.json();
await populateStore(indexedDbDump, "matrix-js-sdk:crypto");
await savePickleKey(window.localStorage.getItem("mx_user_id"), window.localStorage.getItem("mx_device_id"));
console.log("Test data loaded; redirecting to main app");
window.location.replace("/");
}
loadDump();

View file

@ -0,0 +1,137 @@
/*
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 { type Preset, type Visibility } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
import { doTwoWaySasVerification, awaitVerifier } from "./utils";
import { Client } from "../../pages/client";
test.describe("User verification", () => {
// note that there are other tests that check user verification works in `crypto.spec.ts`.
test.use({
displayName: "Alice",
botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" },
room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
// the other user creates a DM
const dmRoomId = await createDMRoom(bob, aliceCredentials.userId);
// accept the DM
await app.viewRoomByName("Bob");
await page.getByRole("button", { name: "Start chatting" }).click();
await use({ roomId: dmRoomId });
},
});
test("can receive a verification request when there is no existing DM", async ({
page,
bot: bob,
user: aliceCredentials,
toasts,
room: { roomId: dmRoomId },
}) => {
// once Alice has joined, Bob starts the verification
const bobVerificationRequest = await bob.evaluateHandle(
async (client, { dmRoomId, aliceCredentials }) => {
const room = client.getRoom(dmRoomId);
while (room.getMember(aliceCredentials.userId)?.membership !== "join") {
await new Promise((resolve) => {
room.once(window.matrixcs.RoomStateEvent.Members, resolve);
});
}
return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId);
},
{ dmRoomId, aliceCredentials },
);
// there should also be a toast
const toast = await toasts.getToast("Verification requested");
// it should contain the details of the requesting user
await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible();
// Accept
await toast.getByRole("button", { name: "Verify User" }).click();
// request verification by emoji
await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click();
/* on the bot side, wait for the verifier to exist ... */
const botVerifier = await awaitVerifier(bobVerificationRequest);
// ... confirm ...
botVerifier.evaluate((verifier) => verifier.verify());
// ... and then check the emoji match
await doTwoWaySasVerification(page, botVerifier);
await page.getByRole("button", { name: "They match" }).click();
await expect(page.getByText("You've successfully verified Bob!")).toBeVisible();
await page.getByRole("button", { name: "Got it" }).click();
});
test("can abort emoji verification when emoji mismatch", async ({
page,
bot: bob,
user: aliceCredentials,
toasts,
room: { roomId: dmRoomId },
}) => {
// once Alice has joined, Bob starts the verification
const bobVerificationRequest = await bob.evaluateHandle(
async (client, { dmRoomId, aliceCredentials }) => {
const room = client.getRoom(dmRoomId);
while (room.getMember(aliceCredentials.userId)?.membership !== "join") {
await new Promise((resolve) => {
room.once(window.matrixcs.RoomStateEvent.Members, resolve);
});
}
return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId);
},
{ dmRoomId, aliceCredentials },
);
// Accept verification via toast
const toast = await toasts.getToast("Verification requested");
await toast.getByRole("button", { name: "Verify User" }).click();
// request verification by emoji
await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click();
/* on the bot side, wait for the verifier to exist ... */
const botVerifier = await awaitVerifier(bobVerificationRequest);
// ... confirm ...
botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {});
// ... and abort the verification
await page.getByRole("button", { name: "They don't match" }).click();
const dialog = page.locator(".mx_Dialog");
await expect(dialog.getByText("Your messages are not secure")).toBeVisible();
await dialog.getByRole("button", { name: "OK" }).click();
await expect(dialog).not.toBeVisible();
});
});
async function createDMRoom(client: Client, userId: string): Promise<string> {
return client.createRoom({
preset: "trusted_private_chat" as Preset,
visibility: "private" as Visibility,
invite: [userId],
is_direct: true,
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
});
}

View file

@ -0,0 +1,390 @@
/*
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, JSHandle, type Page } from "@playwright/test";
import type { CryptoEvent, ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
import type {
EmojiMapping,
ShowSasCallbacks,
VerificationRequest,
Verifier,
VerifierEvent,
} from "matrix-js-sdk/src/crypto-api";
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
import { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { Bot } from "../../pages/bot";
/**
* wait for the given client to receive an incoming verification request, and automatically accept it
*
* @param client - matrix client handle we expect to receive a request
*/
export async function waitForVerificationRequest(client: Client): Promise<JSHandle<VerificationRequest>> {
return client.evaluateHandle((cli) => {
return new Promise<VerificationRequest>((resolve) => {
const onVerificationRequestEvent = async (request: VerificationRequest) => {
await request.accept();
resolve(request);
};
cli.once(
"crypto.verificationRequestReceived" as CryptoEvent.VerificationRequestReceived,
onVerificationRequestEvent,
);
});
});
}
/**
* Automatically handle a SAS verification
*
* Given a verifier which has already been started, wait for the emojis to be received, blindly confirm they
* match, and return them
*
* @param verifier - verifier
* @returns A promise that resolves, with the emoji list, once we confirm the emojis
*/
export function handleSasVerification(verifier: JSHandle<Verifier>): Promise<EmojiMapping[]> {
return verifier.evaluate((verifier) => {
const event = verifier.getShowSasCallbacks();
if (event) return event.sas.emoji;
return new Promise<EmojiMapping[]>((resolve) => {
const onShowSas = (event: ShowSasCallbacks) => {
verifier.off("show_sas" as VerifierEvent, onShowSas);
event.confirm();
resolve(event.sas.emoji);
};
verifier.on("show_sas" as VerifierEvent, onShowSas);
});
});
}
/**
* Check that the user has published cross-signing keys, and that the user's device has been cross-signed.
*/
export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise<void> {
const { userId, deviceId, keys } = await app.client.evaluate(async (cli: MatrixClient) => {
const deviceId = cli.getDeviceId();
const userId = cli.getUserId();
const keys = await cli.downloadKeysForUsers([userId]);
return { userId, deviceId, keys };
});
// there should be three cross-signing keys
expect(keys.master_keys[userId]).toHaveProperty("keys");
expect(keys.self_signing_keys[userId]).toHaveProperty("keys");
expect(keys.user_signing_keys[userId]).toHaveProperty("keys");
// and the device should be signed by the self-signing key
const selfSigningKeyId = Object.keys(keys.self_signing_keys[userId].keys)[0];
expect(keys.device_keys[userId][deviceId]).toBeDefined();
const myDeviceSignatures = keys.device_keys[userId][deviceId].signatures[userId];
expect(myDeviceSignatures[selfSigningKeyId]).toBeDefined();
}
/**
* Check that the current device is connected to the expected key backup.
* Also checks that the decryption key is known and cached locally.
*
* @param page - the page to check
* @param expectedBackupVersion - the version of the backup we expect to be connected to.
* @param checkBackupKeyInCache - whether to check that the backup key is cached locally.
*/
export async function checkDeviceIsConnectedKeyBackup(
page: Page,
expectedBackupVersion: string,
checkBackupKeyInCache: boolean,
): Promise<void> {
// Sanity check the given backup version: if it's null, something went wrong earlier in the test.
if (!expectedBackupVersion) {
throw new Error(
`Invalid backup version passed to \`checkDeviceIsConnectedKeyBackup\`: ${expectedBackupVersion}`,
);
}
await page.getByRole("button", { name: "User menu" }).click();
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click();
await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page.locator(".mx_SecureBackupPanel_advanced").locator("..").click();
if (checkBackupKeyInCache) {
const cacheDecryptionKeyStatusElement = page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(2) td");
await expect(cacheDecryptionKeyStatusElement).toHaveText("cached locally, well formed");
}
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
expectedBackupVersion + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(expectedBackupVersion);
}
/**
* Fill in the login form in element with the given creds.
*
* If a `securityKey` is given, verifies the new device using the key.
*/
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();
// if a securityKey was given, verify the new device
if (securityKey !== undefined) {
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click();
// Fill in the security key
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await page.getByRole("button", { name: "Done" }).click();
}
}
/**
* Click the "sign out" option in Element, and wait for the login page to load
*
* @param page - Playwright `Page` object.
* @param discardKeys - if true, expect a "You'll lose access to your encrypted messages" dialog, and dismiss it.
*/
export async function logOutOfElement(page: Page, discardKeys: boolean = false) {
await page.getByRole("button", { name: "User menu" }).click();
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
if (discardKeys) {
await page.getByRole("button", { name: "I don't want my encrypted messages" }).click();
} else {
await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click();
}
// Wait for the login page to load
await page.getByRole("heading", { name: "Sign in" }).click();
}
/**
* Open the security settings, and verify the current session using the security key.
*
* @param app - `ElementAppPage` wrapper for the playwright `Page`.
* @param securityKey - The security key (i.e., 4S key), set up during a previous session.
*/
export async function verifySession(app: ElementAppPage, securityKey: string) {
const settings = await app.settings.openUserSettings("Security & Privacy");
await settings.getByRole("button", { name: "Verify this session" }).click();
await app.page.getByRole("button", { name: "Verify with Security Key" }).click();
await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
await app.page.getByRole("button", { name: "Done" }).click();
}
/**
* Given a SAS verifier for a bot client:
* - wait for the bot to receive the emojis
* - check that the bot sees the same emoji as the application
*
* @param verifier - a verifier in a bot client
*/
export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Verifier>): Promise<void> {
// on the bot side, wait for the emojis, confirm they match, and return them
const emojis = await handleSasVerification(verifier);
const emojiBlocks = page.locator(".mx_VerificationShowSas_emojiSas_block");
await expect(emojiBlocks).toHaveCount(emojis.length);
// then, check that our application shows an emoji panel with the same emojis.
for (let i = 0; i < emojis.length; i++) {
const emoji = emojis[i];
const emojiBlock = emojiBlocks.nth(i);
const textContent = await emojiBlock.textContent();
// VerificationShowSas munges the case of the emoji descriptions returned by the js-sdk before
// displaying them. Once we drop support for legacy crypto, that code can go away, and so can the
// case-munging here.
expect(textContent.toLowerCase()).toEqual(emoji[0] + emoji[1].toLowerCase());
}
}
/**
* Open the security settings and enable secure key backup.
*
* Assumes that the current device has been cross-signed (which means that we skip a step where we set it up).
*
* Returns the security key
*/
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
await app.settings.openUserSettings("Security & Privacy");
await app.page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = app.page.locator(".mx_Dialog");
// Recovery key is selected by default
await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 });
// copy the text ourselves
const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent();
await copyAndContinue(app.page);
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
return securityKey;
}
/**
* Click on copy and continue buttons to dismiss the security key dialog
*/
export async function copyAndContinue(page: Page) {
await page.getByRole("button", { name: "Copy" }).click();
await page.getByRole("button", { name: "Continue" }).click();
}
/**
* Create a shared, unencrypted room with the given user, and wait for them to join
*
* @param other - UserID of the other user
* @param opts - other options for the createRoom call
*
* @returns a promise which resolves to the room ID
*/
export async function createSharedRoomWithUser(
app: ElementAppPage,
other: string,
opts: Omit<ICreateRoomOpts, "invite"> = { name: "TestRoom" },
): Promise<string> {
const roomId = await app.client.createRoom({ ...opts, invite: [other] });
await app.viewRoomById(roomId);
// wait for the other user to join the room, otherwise our attempt to open his user details may race
// with his join.
await expect(app.page.getByText(" joined the room", { exact: false })).toBeVisible();
return roomId;
}
/**
* Send a message in the current room
* @param page
* @param message - The message text to send
*/
export async function sendMessageInCurrentRoom(page: Page, message: string): Promise<void> {
await page.locator(".mx_MessageComposer").getByRole("textbox").fill(message);
await page.getByTestId("sendmessagebtn").click();
}
/**
* Create a room with the given name and encryption status using the room creation dialog.
*
* @param roomName - The name of the room to create
* @param isEncrypted - Whether the room should be encrypted
*/
export async function createRoom(page: Page, roomName: string, isEncrypted: boolean): Promise<void> {
await page.getByRole("button", { name: "Add room" }).click();
await page.locator(".mx_IconizedContextMenu").getByRole("menuitem", { name: "New room" }).click();
const dialog = page.locator(".mx_Dialog");
await dialog.getByLabel("Name").fill(roomName);
if (!isEncrypted) {
// it's enabled by default
await page.getByLabel("Enable end-to-end encryption").click();
}
await dialog.getByRole("button", { name: "Create room" }).click();
// Wait for the client to process the encryption event before carrying on (and potentially sending events).
if (isEncrypted) {
await expect(page.getByText("Encryption enabled")).toBeVisible();
}
}
/**
* Configure the given MatrixClient to auto-accept any invites
* @param client - the client to configure
*/
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()) {
cli.joinRoom(member.roomId);
}
});
});
}
/**
* Verify a user by emoji
* @param page - the page to use
* @param bob - the user to verify
*/
export const verify = async (app: ElementAppPage, bob: Bot) => {
const page = app.page;
const bobsVerificationRequestPromise = waitForVerificationRequest(bob);
const roomInfo = await app.toggleRoomInfoPanel();
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await roomInfo.getByText("Bob").click();
await roomInfo.getByRole("button", { name: "Verify" }).click();
await roomInfo.getByRole("button", { name: "Start Verification" }).click();
// this requires creating a DM, so can take a while. Give it a longer timeout.
await roomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 });
const request = await bobsVerificationRequestPromise;
// the bot user races with the Element user to hit the "verify by emoji" button
const verifier = await request.evaluateHandle((request) => request.startVerification("m.sas.v1"));
await doTwoWaySasVerification(page, verifier);
await roomInfo.getByRole("button", { name: "They match" }).click();
await expect(roomInfo.getByText("You've successfully verified Bob!")).toBeVisible();
await roomInfo.getByRole("button", { name: "Got it" }).click();
};
/**
* Wait for a verifier to exist for a VerificationRequest
*
* @param botVerificationRequest
*/
export async function awaitVerifier(
botVerificationRequest: JSHandle<VerificationRequest>,
): Promise<JSHandle<Verifier>> {
return botVerificationRequest.evaluateHandle(async (verificationRequest) => {
while (!verificationRequest.verifier) {
await new Promise((r) => verificationRequest.once("change" as any, r));
}
return verificationRequest.verifier;
});
}
/** Log in a second device for the given bot user */
export async function createSecondBotDevice(page: Page, homeserver: HomeserverInstance, bob: Bot) {
const bobSecondDevice = new Bot(page, homeserver, {
bootstrapSecretStorage: false,
bootstrapCrossSigning: false,
});
bobSecondDevice.setCredentials(await homeserver.loginUser(bob.credentials.userId, bob.credentials.password));
await bobSecondDevice.prepareClient();
return bobSecondDevice;
}

View file

@ -0,0 +1,366 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Locator, Page } from "@playwright/test";
import type { EventType, IContent, ISendEventResponse, MsgType, Visibility } from "matrix-js-sdk/src/matrix";
import { expect, test } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { SettingLevel } from "../../../src/settings/SettingLevel";
async function sendEvent(app: ElementAppPage, roomId: string): Promise<ISendEventResponse> {
return app.client.sendEvent(roomId, null, "m.room.message" as EventType, {
msgtype: "m.text" as MsgType,
body: "Message",
});
}
/** generate a message event which will take up some room on the page. */
function mkPadding(n: number): IContent {
return {
msgtype: "m.text" as MsgType,
body: `padding ${n}`,
format: "org.matrix.custom.html",
formatted_body: `<h3>Test event ${n}</h3>\n`.repeat(10),
};
}
test.describe("Editing", () => {
// Edit "Message"
const editLastMessage = async (page: Page, edit: string) => {
const eventTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last");
await eventTile.hover();
await eventTile.getByRole("button", { name: "Edit" }).click();
const textbox = page.getByRole("textbox", { name: "Edit message" });
await textbox.fill(edit);
await textbox.press("Enter");
};
const clickEditedMessage = async (page: Page, edited: string) => {
// Assert that the message was edited
const eventTile = page.locator(".mx_EventTile", { hasText: edited });
await expect(eventTile).toBeVisible();
// Click to display the message edit history dialog
await eventTile.getByText("(edited)").click();
};
const clickButtonViewSource = async (locator: Locator) => {
const eventTile = locator.locator(".mx_EventTile_line");
await eventTile.hover();
// Assert that "View Source" button is rendered and click it
await eventTile.getByRole("button", { name: "View Source" }).click();
};
test.use({
displayName: "Edith",
room: async ({ user, app }, use) => {
const roomId = await app.client.createRoom({ name: "Test room" });
await use({ roomId });
},
botCreateOpts: { displayName: "Bob" },
});
test("should render and interact with the message edit history dialog", async ({ page, user, app, room }) => {
// Click the "Remove" button on the message edit history dialog
const clickButtonRemove = async (locator: Locator) => {
const eventTileLine = locator.locator(".mx_EventTile_line");
await eventTileLine.hover();
await eventTileLine.getByRole("button", { name: "Remove" }).click();
};
await page.goto(`#/room/${room.roomId}`);
// Send "Message"
await sendEvent(app, room.roomId);
// Edit "Message" to "Massage"
await editLastMessage(page, "Massage");
// Assert that the edit label is visible
await expect(page.locator(".mx_EventTile_edited")).toBeVisible();
await clickEditedMessage(page, "Massage");
// Assert that the message edit history dialog is rendered
const dialog = page.getByRole("dialog");
const li = dialog.getByRole("listitem").last();
// Assert CSS styles which are difficult or cannot be detected with snapshots are applied as expected
await expect(li).toHaveCSS("clear", "both");
const timestamp = li.locator(".mx_EventTile .mx_MessageTimestamp");
await expect(timestamp).toHaveCSS("position", "absolute");
await expect(timestamp).toHaveCSS("inset-inline-start", "0px");
await expect(timestamp).toHaveCSS("text-align", "center");
// Assert that monospace characters can fill the content line as expected
await expect(li.locator(".mx_EventTile .mx_EventTile_content")).toHaveCSS("margin-inline-end", "0px");
// Assert that zero block start padding is applied to mx_EventTile as expected
// See: .mx_EventTile on _EventTile.pcss
await expect(li.locator(".mx_EventTile")).toHaveCSS("padding-block-start", "0px");
// Assert that the date separator is rendered at the top
await expect(dialog.getByRole("listitem").first().locator("h2", { hasText: "today" })).toHaveCSS(
"text-transform",
"capitalize",
);
{
// Assert that the edited message is rendered under the date separator
const tile = dialog.locator("li:nth-child(2) .mx_EventTile");
// Assert that the edited message body consists of both deleted character and inserted character
// Above the first "e" of "Message" was replaced with "a"
await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage");
const body = tile.locator(".mx_EventTile_content .mx_EventTile_body");
await expect(body.locator(".mx_EditHistoryMessage_deletion").getByText("e")).toBeVisible();
await expect(body.locator(".mx_EditHistoryMessage_insertion").getByText("a")).toBeVisible();
}
// Assert that the original message is rendered at the bottom
await expect(
dialog
.locator("li:nth-child(3) .mx_EventTile")
.locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Message" }),
).toBeVisible();
// Take a snapshot of the dialog
await expect(dialog).toMatchScreenshot("message-edit-history-dialog.png", {
mask: [page.locator(".mx_MessageTimestamp")],
});
{
const tile = dialog.locator("li:nth-child(2) .mx_EventTile");
await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage");
// Click the "Remove" button again
await clickButtonRemove(tile);
}
// Do nothing and close the dialog to confirm that the message edit history dialog is rendered
await app.closeDialog();
{
// Assert that the message edit history dialog is rendered again after it was closed
const tile = dialog.locator("li:nth-child(2) .mx_EventTile");
await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage");
// Click the "Remove" button again
await clickButtonRemove(tile);
}
// This time remove the message really
const textInputDialog = page.locator(".mx_TextInputDialog");
await textInputDialog.getByRole("textbox", { name: "Reason (optional)" }).fill("This is a test."); // Reason
await textInputDialog.getByRole("button", { name: "Remove" }).click();
// Assert that the message edit history dialog is rendered again
const messageEditHistoryDialog = page.locator(".mx_MessageEditHistoryDialog");
// Assert that the date is rendered
await expect(
messageEditHistoryDialog.getByRole("listitem").first().locator("h2", { hasText: "today" }),
).toHaveCSS("text-transform", "capitalize");
// Assert that the original message is rendered under the date on the dialog
await expect(
messageEditHistoryDialog
.locator("li:nth-child(2) .mx_EventTile")
.locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Message" }),
).toBeVisible();
// Assert that the edited message is gone
await expect(
messageEditHistoryDialog.locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Meassage" }),
).not.toBeVisible();
await app.closeDialog();
// Assert that the redaction placeholder is rendered
await expect(
page
.locator(".mx_RoomView_MessageList")
.locator(".mx_EventTile_last .mx_RedactedBody", { hasText: "Message deleted" }),
).toBeVisible();
});
test("should render 'View Source' button in developer mode on the message edit history dialog", async ({
page,
user,
app,
room,
}) => {
await page.goto(`#/room/${room.roomId}`);
// Send "Message"
await sendEvent(app, room.roomId);
// Edit "Message" to "Massage"
await editLastMessage(page, "Massage");
// Assert that the edit label is visible
await expect(page.locator(".mx_EventTile_edited")).toBeVisible();
await clickEditedMessage(page, "Massage");
{
const dialog = page.getByRole("dialog");
// Assert that the original message is rendered
const li = dialog.locator("li:nth-child(3)");
// Assert that "View Source" is not rendered
const eventLine = li.locator(".mx_EventTile_line");
await eventLine.hover();
await expect(eventLine.getByRole("button", { name: "View Source" })).not.toBeVisible();
}
await app.closeDialog();
// Enable developer mode
await app.settings.setValue("developerMode", null, SettingLevel.ACCOUNT, true);
await clickEditedMessage(page, "Massage");
{
const dialog = page.getByRole("dialog");
{
// Assert that the edited message is rendered
const li = dialog.locator("li:nth-child(2)");
// Assert that "Remove" button for the original message is rendered
const line = li.locator(".mx_EventTile_line");
await line.hover();
await expect(line.getByRole("button", { name: "Remove" })).toBeVisible();
await clickButtonViewSource(li);
}
// Assert that view source dialog is rendered and close the dialog
await app.closeDialog();
{
// Assert that the original message is rendered
const li = dialog.locator("li:nth-child(3)");
// Assert that "Remove" button for the original message does not exist
const line = li.locator(".mx_EventTile_line");
await line.hover();
await expect(line.getByRole("button", { name: "Remove" })).not.toBeVisible();
await clickButtonViewSource(li);
}
// Assert that view source dialog is rendered and close the dialog
await app.closeDialog();
}
});
test("should close the composer when clicking save after making a change and undoing it", async ({
page,
user,
app,
room,
axe,
checkA11y,
}) => {
axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here
axe.exclude(".mx_Tooltip_visible"); // XXX: this is fine but would be good to fix
await page.goto(`#/room/${room.roomId}`);
await sendEvent(app, room.roomId);
{
// Edit message
const tile = page.locator(".mx_RoomView_body .mx_EventTile").last();
await expect(tile.getByText("Message", { exact: true })).toBeVisible();
const line = tile.locator(".mx_EventTile_line");
await line.hover();
await line.getByRole("button", { name: "Edit" }).click();
await checkA11y();
const editComposer = page.getByRole("textbox", { name: "Edit message" });
await editComposer.pressSequentially("Foo");
await editComposer.press("Backspace");
await editComposer.press("Backspace");
await editComposer.press("Backspace");
await editComposer.press("Enter");
await app.getComposerField().hover(); // XXX: move the hover to get rid of the "Edit" tooltip
await checkA11y();
}
await expect(
page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: "Message" }),
).toBeVisible();
// Assert that the edit composer has gone away
await expect(page.getByRole("textbox", { name: "Edit message" })).not.toBeVisible();
});
test("should correctly display events which are edited, where we lack the edit event", async ({
page,
user,
app,
axe,
checkA11y,
bot: bob,
}) => {
// This tests the behaviour when a message has been edited some time after it has been sent, and we
// jump back in room history to view the event, but do not have the actual edit event.
//
// In that scenario, we rely on the server to replace the content (pre-MSC3925), or do it ourselves based on
// the bundled edit event (post-MSC3925).
//
// To test it, we need to have a room with lots of events in, so we can jump around the timeline without
// paginating in the event itself. Hence, we create a bot user which creates the room and populates it before
// we join.
// "bob" now creates the room, and sends a load of events in it. Note that all of this happens via calls on
// the js-sdk rather than Cypress commands, so uses regular async/await.
const testRoomId = await bob.createRoom({ name: "TestRoom", visibility: "public" as Visibility });
const { event_id: originalEventId } = await bob.sendMessage(testRoomId, {
body: "original",
msgtype: "m.text",
});
// send a load of padding events. We make them large, so that they fill the whole screen
// and the client doesn't end up paginating into the event we want.
let i = 0;
while (i < 10) {
await bob.sendMessage(testRoomId, mkPadding(i++));
}
// ... then the edit ...
const editEventId = (
await bob.sendMessage(testRoomId, {
"m.new_content": { body: "Edited body", msgtype: "m.text" },
"m.relates_to": {
rel_type: "m.replace",
event_id: originalEventId,
},
"body": "* edited",
"msgtype": "m.text",
})
).event_id;
// ... then a load more padding ...
while (i < 20) {
await bob.sendMessage(testRoomId, mkPadding(i++));
}
// now have the cypress user join the room, jump to the original event, and wait for the event to be visible
await app.client.joinRoom(testRoomId);
await app.viewRoomByName("TestRoom");
await page.goto(`#/room/${testRoomId}/${originalEventId}`);
const messageTile = page.locator(`[data-event-id="${originalEventId}"]`);
// at this point, the edit event should still be unknown
const timeline = await app.client.evaluate(
(cli, { testRoomId, editEventId }) => cli.getRoom(testRoomId).getTimelineForEvent(editEventId),
{ testRoomId, editEventId },
);
expect(timeline).toBeNull();
// nevertheless, the event should be updated
await expect(messageTile.locator(".mx_EventTile_body")).toHaveText("Edited body");
await expect(messageTile.locator(".mx_EventTile_edited")).toBeVisible();
});
});

View file

@ -0,0 +1,37 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Image Upload", () => {
test.use({
displayName: "Alice",
});
test.beforeEach(async ({ page, app, user }) => {
await app.client.createRoom({ name: "My Pictures" });
await app.viewRoomByName("My Pictures");
// Wait until configuration is finished
await expect(
page
.locator(".mx_GenericEventListSummary[data-layout='group'] .mx_GenericEventListSummary_summary")
.getByText(`${user.displayName} created and configured the room.`),
).toBeVisible();
});
test("should show image preview when uploading an image", async ({ page, app }) => {
await page
.locator(".mx_MessageComposer_actions input[type='file']")
.setInputFiles("playwright/sample-files/riot.png");
await expect(page.getByRole("button", { name: "Upload" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("image-upload-preview.png");
});
});

View file

@ -0,0 +1,69 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import { selectHomeserver } from "../utils";
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";
test.describe("Forgot Password", () => {
test.use({
startHomeserverOpts: ({ mailhog }, use) =>
use({
template: "email",
variables: {
SMTP_HOST: "host.containers.internal",
SMTP_PORT: mailhog.instance.smtpPort,
},
}),
});
test("renders properly", async ({ page, homeserver }) => {
await page.goto("/");
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.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", async ({ page, homeserver }) => {
const user = await homeserver.registerUser(username, password);
await homeserver.setThreepid(user.userId, "email", email);
await page.goto("/");
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("textbox", { name: "Email address" }).fill(email);
await page.getByRole("button", { name: "Send email" }).click();
await page.getByRole("button", { name: "Next" }).click();
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 expect(page.getByRole("button", { name: "Resend" })).toBeInViewport();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("forgot-password-verify-email.png");
});
});

View file

@ -0,0 +1,120 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
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 { openIntegrationManager } from "./utils";
const ROOM_NAME = "Integration Manager Test";
const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal";
const INTEGRATION_MANAGER_HTML = `
<html lang="en">
<head>
<title>Fake Integration Manager</title>
</head>
<body>
<button name="Send" id="send-action">Press to send action</button>
<button name="Close" id="close">Press to close</button>
<p id="message-response">No response</p>
<script>
document.getElementById("send-action").onclick = () => {
window.parent.postMessage(
{
action: "get_open_id_token",
},
'*',
);
};
document.getElementById("close").onclick = () => {
window.parent.postMessage(
{
action: "close_scalar",
},
'*',
);
};
// Listen for a postmessage response
window.addEventListener("message", (event) => {
document.getElementById("message-response").innerText = JSON.stringify(event.data);
});
</script>
</body>
</html>
`;
async function sendActionFromIntegrationManager(page: Page, integrationManagerUrl: string) {
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await iframe.getByRole("button", { name: "Press to send action" }).click();
}
test.describe("Integration Manager: Get OpenID Token", () => {
test.use({
displayName: "Alice",
room: async ({ user, app }, use) => {
const roomId = await app.client.createRoom({
name: ROOM_NAME,
});
await use({ roomId });
},
});
let integrationManagerUrl: string;
test.beforeEach(async ({ page, webserver }) => {
integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML);
await page.addInitScript(
({ token, integrationManagerUrl }) => {
window.localStorage.setItem("mx_scalar_token", token);
window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token);
},
{
token: INTEGRATION_MANAGER_TOKEN,
integrationManagerUrl,
},
);
});
test.beforeEach(async ({ page, user, app, room }) => {
await app.client.setAccountData("m.widgets", {
"m.integration_manager": {
content: {
type: "m.integration_manager",
name: "Integration Manager",
url: integrationManagerUrl,
data: {
api_url: integrationManagerUrl,
},
},
id: "integration-manager",
},
});
// Succeed when checking the token is valid
await page.route(
`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`,
async (route) => {
await route.fulfill({
json: {
user_id: user.userId,
},
});
},
);
await app.viewRoomByName(ROOM_NAME);
});
test("should successfully obtain an openID token", async ({ page, app }) => {
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl);
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response").getByText(/access_token/)).toBeVisible();
});
});

View file

@ -0,0 +1,218 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
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 { openIntegrationManager } from "./utils";
const ROOM_NAME = "Integration Manager Test";
const USER_DISPLAY_NAME = "Alice";
const BOT_DISPLAY_NAME = "Bob";
const KICK_REASON = "Goodbye";
const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal";
const INTEGRATION_MANAGER_HTML = `
<html lang="en">
<head>
<title>Fake Integration Manager</title>
</head>
<body>
<input type="text" id="target-room-id"/>
<input type="text" id="target-user-id"/>
<button name="Send" id="send-action">Press to send action</button>
<button name="Close" id="close">Press to close</button>
<script>
document.getElementById("send-action").onclick = () => {
window.parent.postMessage(
{
action: "kick",
room_id: document.getElementById("target-room-id").value,
user_id: document.getElementById("target-user-id").value,
reason: "${KICK_REASON}",
},
'*',
);
};
document.getElementById("close").onclick = () => {
window.parent.postMessage(
{
action: "close_scalar",
},
'*',
);
};
</script>
</body>
</html>
`;
async function closeIntegrationManager(page: Page, integrationManagerUrl: string) {
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await iframe.getByRole("button", { name: "Press to close" }).click();
}
async function sendActionFromIntegrationManager(
page: Page,
integrationManagerUrl: string,
targetRoomId: string,
targetUserId: string,
) {
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await iframe.locator("#target-room-id").fill(targetRoomId);
await iframe.locator("#target-user-id").fill(targetUserId);
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) {
// 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", () => {
test.use({
displayName: "Alice",
room: async ({ user, app }, use) => {
const roomId = await app.client.createRoom({
name: ROOM_NAME,
});
await use({ roomId });
},
botCreateOpts: {
displayName: BOT_DISPLAY_NAME,
autoAcceptInvites: true,
},
});
let integrationManagerUrl: string;
test.beforeEach(async ({ page, webserver }) => {
integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML);
await page.addInitScript(
({ token, integrationManagerUrl }) => {
window.localStorage.setItem("mx_scalar_token", token);
window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token);
},
{
token: INTEGRATION_MANAGER_TOKEN,
integrationManagerUrl,
},
);
});
test.beforeEach(async ({ page, user, app, room }) => {
await app.client.setAccountData("m.widgets", {
"m.integration_manager": {
content: {
type: "m.integration_manager",
name: "Integration Manager",
url: integrationManagerUrl,
data: {
api_url: integrationManagerUrl,
},
},
id: "integration-manager",
},
});
// Succeed when checking the token is valid
await page.route(
`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`,
async (route) => {
await route.fulfill({
json: {
user_id: user.userId,
},
});
},
);
await app.viewRoomByName(ROOM_NAME);
});
test("should kick the target", async ({ page, app, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, true);
});
test("should not kick the target if lacking permissions", async ({ page, app, user, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await app.client.sendStateEvent(room.roomId, "m.room.power_levels", {
kick: 50,
users: {
[user.userId]: 0,
},
});
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
});
test("should no-op if the target already left", async ({ page, app, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await targetUser.leave(room.roomId);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
});
test("should no-op if the target was banned", async ({ page, app, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await app.client.ban(room.roomId, targetUser.credentials.userId);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
});
test("should no-op if the target was never a room member", async ({ page, app, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
});
});

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