mirror of
https://github.com/element-hq/element-web
synced 2024-11-28 20:38:55 +03:00
Merge pull request #28213 from element-hq/t3chguy/repo-merge
This commit is contained in:
commit
f4a254a303
3279 changed files with 587102 additions and 1794 deletions
|
@ -1,16 +1,8 @@
|
||||||
|
# Copyright 2024 New Vector Ltd.
|
||||||
# Copyright 2017 Aviral Dasgupta
|
# Copyright 2017 Aviral Dasgupta
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||||
# you may not use this file except in compliance with the License.
|
# Please see LICENSE files in the repository root for full details.
|
||||||
# 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.
|
|
||||||
|
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
|
@ -27,3 +19,6 @@ indent_size = 4
|
||||||
|
|
||||||
[package.json]
|
[package.json]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.tsx.snap]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
src/vector/modernizr.js
|
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
|
# Legacy skinning file that some people might still have
|
||||||
src/component-index.js
|
src/component-index.js
|
||||||
# Auto-generated file
|
# Auto-generated file
|
||||||
|
|
|
@ -45,24 +45,12 @@ module.exports = {
|
||||||
name: "matrix-js-sdk/src/index",
|
name: "matrix-js-sdk/src/index",
|
||||||
message: "Please use matrix-js-sdk/src/matrix instead",
|
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: [
|
patterns: [
|
||||||
{
|
{
|
||||||
group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"],
|
group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"],
|
||||||
message: "Please use matrix-js-sdk/src/* instead",
|
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",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
261
.eslintrc.js
261
.eslintrc.js
|
@ -1,6 +1,6 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: ["matrix-org"],
|
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: {
|
parserOptions: {
|
||||||
project: ["./tsconfig.json"],
|
project: ["./tsconfig.json"],
|
||||||
},
|
},
|
||||||
|
@ -8,36 +8,53 @@ module.exports = {
|
||||||
browser: true,
|
browser: true,
|
||||||
node: true,
|
node: true,
|
||||||
},
|
},
|
||||||
|
globals: {
|
||||||
|
LANGUAGES_FILE: "readonly",
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// Things we do that break the ideal style
|
// Things we do that break the ideal style
|
||||||
quotes: "off",
|
"no-constant-condition": "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
|
|
||||||
"prefer-promise-reject-errors": "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
|
// Bind or arrow functions in props causes performance issues (but we
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
// currently use them in some places).
|
||||||
// We're okay with assertion errors when we ask for them
|
// It's disabled here, but we should using it sparingly.
|
||||||
"@typescript-eslint/no-non-null-assertion": "off",
|
"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 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": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
paths: [
|
paths: [
|
||||||
|
{
|
||||||
|
name: "@testing-library/react",
|
||||||
|
message: "Please use jest-matrix-react instead",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "matrix-js-sdk",
|
name: "matrix-js-sdk",
|
||||||
message: "Please use matrix-js-sdk/src/matrix instead",
|
message: "Please use matrix-js-sdk/src/matrix instead",
|
||||||
|
@ -59,37 +76,213 @@ module.exports = {
|
||||||
message: "Please use matrix-js-sdk/src/matrix instead",
|
message: "Please use matrix-js-sdk/src/matrix instead",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matrix-react-sdk",
|
name: "emojibase-regex",
|
||||||
message: "Please use matrix-react-sdk/src/index instead",
|
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.",
|
||||||
{
|
|
||||||
name: "matrix-react-sdk/",
|
|
||||||
message: "Please use matrix-react-sdk/src/index instead",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
patterns: [
|
patterns: [
|
||||||
{
|
{
|
||||||
group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"],
|
group: [
|
||||||
message: "Please use matrix-js-sdk/src/* instead",
|
"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/**"],
|
group: ["emojibase-regex/emoji*"],
|
||||||
message: "Please use matrix-react-sdk/src/* instead",
|
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: {
|
rules: {
|
||||||
// We don't need super strict typing in test utilities
|
// We don't need super strict typing in test utilities
|
||||||
"@typescript-eslint/explicit-function-return-type": "off",
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
"@typescript-eslint/explicit-member-accessibility": "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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
# prettier
|
# prettier
|
||||||
|
526645c79160ab1ad4b4c3845de27d51263a405e
|
||||||
7921a6cbf86b035d2b0c1daecb4c24beaf5a5abc
|
7921a6cbf86b035d2b0c1daecb4c24beaf5a5abc
|
||||||
|
|
13
.github/CODEOWNERS
vendored
13
.github/CODEOWNERS
vendored
|
@ -2,4 +2,17 @@
|
||||||
/.github/workflows/** @element-hq/element-web-team
|
/.github/workflows/** @element-hq/element-web-team
|
||||||
/package.json @element-hq/element-web-team
|
/package.json @element-hq/element-web-team
|
||||||
/yarn.lock @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
|
/src/i18n/strings
|
||||||
|
# Ignore the synapse plugin as this is updated by GHA for docker image updating
|
||||||
|
/playwright/plugins/homeserver/synapse/index.ts
|
||||||
|
|
||||||
|
|
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -5,4 +5,4 @@
|
||||||
- [ ] Tests written for new code (and old code if feasible).
|
- [ ] Tests written for new code (and old code if feasible).
|
||||||
- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation.
|
- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation.
|
||||||
- [ ] Linter and other CI checks pass.
|
- [ ] 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)
|
||||||
|
|
2
.github/release-drafter.yml
vendored
2
.github/release-drafter.yml
vendored
|
@ -1,3 +1,3 @@
|
||||||
_extends: element-hq/matrix-react-sdk
|
_extends: matrix-org/matrix-js-sdk
|
||||||
version-resolver:
|
version-resolver:
|
||||||
default: patch
|
default: patch
|
||||||
|
|
11
.github/workflows/docs.yml
vendored
11
.github/workflows/docs.yml
vendored
|
@ -30,12 +30,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
path: element-web
|
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
|
- name: Fetch matrix-js-sdk
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
@ -52,7 +46,7 @@ jobs:
|
||||||
working-directory: element-web
|
working-directory: element-web
|
||||||
run: |
|
run: |
|
||||||
yarn install --frozen-lockfile
|
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
|
echo "- [Automations](automations.md)" >> docs/SUMMARY.md
|
||||||
|
|
||||||
- name: Setup mdBook
|
- name: Setup mdBook
|
||||||
|
@ -74,9 +68,6 @@ jobs:
|
||||||
mv element-web/docs/lib docs/
|
mv element-web/docs/lib docs/
|
||||||
mv element-web/docs "docs/Element Web"
|
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/README.md matrix-js-sdk/docs/
|
||||||
mv matrix-js-sdk/docs "docs/Matrix JS SDK"
|
mv matrix-js-sdk/docs "docs/Matrix JS SDK"
|
||||||
|
|
||||||
|
|
43
.github/workflows/end-to-end-tests-netlify.yaml
vendored
Normal file
43
.github/workflows/end-to-end-tests-netlify.yaml
vendored
Normal 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: ${{ vars.NETLIFY_SITE_ID }}
|
||||||
|
desc: Playwright Report
|
||||||
|
deployment_env: EndToEndTests
|
||||||
|
prefix: "e2e-"
|
189
.github/workflows/end-to-end-tests.yaml
vendored
189
.github/workflows/end-to-end-tests.yaml
vendored
|
@ -1,29 +1,190 @@
|
||||||
# Triggers after the "Downstream artifacts" build has finished, to run the
|
# Produce a build of element-web with this version of react-sdk
|
||||||
# matrix-react-sdk playwright tests (with access to repo secrets)
|
# and any matching branches of element-web and js-sdk, output it
|
||||||
|
# as an artifact and run end-to-end tests.
|
||||||
name: matrix-react-sdk End to End Tests
|
name: End to End Tests
|
||||||
on:
|
on:
|
||||||
|
pull_request: {}
|
||||||
merge_group:
|
merge_group:
|
||||||
types: [checks_requested]
|
types: [checks_requested]
|
||||||
pull_request: {}
|
|
||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
repository_dispatch:
|
||||||
|
types: [element-web-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."
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
|
||||||
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
# fetchdep.sh needs to know our PR number
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
jobs:
|
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 }}
|
||||||
|
run: |
|
||||||
|
scripts/layered.sh
|
||||||
|
JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD)
|
||||||
|
VECTOR_SHA=$(git rev-parse --short=12 HEAD)
|
||||||
|
echo "VERSION=$VECTOR_SHA--js-$JSSDK_SHA" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Copy config
|
||||||
|
run: cp element.io/develop/config.json config.json
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
CI_PACKAGE: true
|
||||||
|
VERSION: "${{ steps.layered_build.outputs.VERSION }}"
|
||||||
|
run: |
|
||||||
|
yarn build
|
||||||
|
echo $VERSION > webapp/version
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: webapp
|
||||||
|
path: webapp
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
playwright:
|
playwright:
|
||||||
name: Playwright
|
name: "Run Tests ${{ matrix.runner }}/${{ strategy.job-total }}"
|
||||||
uses: element-hq/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml@develop
|
needs: build
|
||||||
|
if: inputs.skip != true
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
issues: read
|
issues: read
|
||||||
pull-requests: 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:
|
with:
|
||||||
element-web-sha: ${{ github.sha }}
|
persist-credentials: false
|
||||||
react-sdk-repository: element-hq/matrix-react-sdk
|
repository: element-hq/element-web
|
||||||
# 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.
|
- name: 📥 Download artifact
|
||||||
skip: ${{ github.event_name != 'merge_group' }}
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: webapp
|
||||||
|
path: webapp
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
cache: "yarn"
|
||||||
|
cache-dependency-path: yarn.lock
|
||||||
|
node-version: "lts/*"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Get installed Playwright version
|
||||||
|
id: playwright
|
||||||
|
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'
|
||||||
|
run: yarn playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }}
|
||||||
|
|
||||||
|
- name: Upload blob report to GitHub Actions Artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: all-blob-reports-${{ matrix.runner }}
|
||||||
|
path: 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
48
.github/workflows/netlify.yaml
vendored
Normal 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: ${{ vars.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.
|
1
.github/workflows/pending-reviews.yaml
vendored
1
.github/workflows/pending-reviews.yaml
vendored
|
@ -64,7 +64,6 @@ jobs:
|
||||||
const repos = [
|
const repos = [
|
||||||
"element-hq/element-desktop",
|
"element-hq/element-desktop",
|
||||||
"element-hq/element-web",
|
"element-hq/element-web",
|
||||||
"element-hq/matrix-react-sdk",
|
|
||||||
"matrix-org/matrix-js-sdk",
|
"matrix-org/matrix-js-sdk",
|
||||||
];
|
];
|
||||||
const teams = [
|
const teams = [
|
||||||
|
|
45
.github/workflows/playwright-image-updates.yaml
vendored
Normal file
45
.github/workflows/playwright-image-updates.yaml
vendored
Normal 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 }}
|
16
.github/workflows/pull_request_base_branch.yaml
vendored
Normal file
16
.github/workflows/pull_request_base_branch.yaml
vendored
Normal 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}`);
|
||||||
|
}
|
2
.github/workflows/release-drafter.yml
vendored
2
.github/workflows/release-drafter.yml
vendored
|
@ -7,5 +7,3 @@ concurrency: ${{ github.workflow }}
|
||||||
jobs:
|
jobs:
|
||||||
draft:
|
draft:
|
||||||
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop
|
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop
|
||||||
with:
|
|
||||||
include-changes: matrix-react-sdk
|
|
||||||
|
|
1
.github/workflows/release-gitflow.yml
vendored
1
.github/workflows/release-gitflow.yml
vendored
|
@ -11,5 +11,4 @@ jobs:
|
||||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
with:
|
with:
|
||||||
dependencies: |
|
dependencies: |
|
||||||
matrix-react-sdk
|
|
||||||
matrix-js-sdk
|
matrix-js-sdk
|
||||||
|
|
33
.github/workflows/release_prepare.yml
vendored
33
.github/workflows/release_prepare.yml
vendored
|
@ -12,11 +12,6 @@ on:
|
||||||
required: true
|
required: true
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
matrix-react-sdk:
|
|
||||||
description: Prepare matrix-react-sdk
|
|
||||||
required: true
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
matrix-js-sdk:
|
matrix-js-sdk:
|
||||||
description: Prepare matrix-js-sdk
|
description: Prepare matrix-js-sdk
|
||||||
required: true
|
required: true
|
||||||
|
@ -25,9 +20,6 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Checkout Element Desktop
|
- name: Checkout Element Desktop
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
@ -49,16 +41,6 @@ jobs:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
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
|
- name: Checkout Matrix JS SDK
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
if: inputs.matrix-js-sdk
|
if: inputs.matrix-js-sdk
|
||||||
|
@ -83,10 +65,6 @@ jobs:
|
||||||
if: inputs.element-web
|
if: inputs.element-web
|
||||||
run: |
|
run: |
|
||||||
git -C "element-web" merge origin/develop
|
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
|
- name: Merge JS SDK
|
||||||
if: inputs.matrix-js-sdk
|
if: inputs.matrix-js-sdk
|
||||||
run: |
|
run: |
|
||||||
|
@ -106,17 +84,6 @@ jobs:
|
||||||
check-name: draft
|
check-name: draft
|
||||||
allowed-conclusions: success
|
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
|
- name: Wait for element-web draft
|
||||||
if: inputs.element-web
|
if: inputs.element-web
|
||||||
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork
|
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork
|
||||||
|
|
3
.github/workflows/sonarqube.yml
vendored
3
.github/workflows/sonarqube.yml
vendored
|
@ -10,7 +10,10 @@ concurrency:
|
||||||
jobs:
|
jobs:
|
||||||
sonarqube:
|
sonarqube:
|
||||||
name: 🩻 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
|
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
|
||||||
secrets:
|
secrets:
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
with:
|
||||||
|
sharded: true
|
||||||
|
|
50
.github/workflows/static_analysis.yaml
vendored
50
.github/workflows/static_analysis.yaml
vendored
|
@ -7,10 +7,15 @@ on:
|
||||||
types: [checks_requested]
|
types: [checks_requested]
|
||||||
repository_dispatch:
|
repository_dispatch:
|
||||||
types: [element-web-notify]
|
types: [element-web-notify]
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# These must be set for fetchdep.sh to get the right branch
|
# These must be set for fetchdep.sh to get the right branch
|
||||||
REPOSITORY: ${{ github.repository }}
|
REPOSITORY: ${{ github.repository }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ts_lint:
|
ts_lint:
|
||||||
name: "Typescript Syntax Check"
|
name: "Typescript Syntax Check"
|
||||||
|
@ -29,11 +34,50 @@ jobs:
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: "yarn run lint:types"
|
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:
|
i18n_lint:
|
||||||
name: "i18n Check"
|
name: "i18n Check"
|
||||||
uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main
|
uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main
|
||||||
with:
|
with:
|
||||||
hardcoded-words: "Element"
|
hardcoded-words: "Element"
|
||||||
|
allowed-hardcoded-keys: |
|
||||||
|
console_dev_note
|
||||||
|
labs|element_call_video_rooms
|
||||||
|
labs|feature_disable_call_per_sender_encryption
|
||||||
|
voip|element_call
|
||||||
|
error|invalid_json
|
||||||
|
error|misconfigured
|
||||||
|
welcome_to_element
|
||||||
|
|
||||||
|
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:
|
js_lint:
|
||||||
name: "ESLint"
|
name: "ESLint"
|
||||||
|
@ -64,9 +108,9 @@ jobs:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
|
|
||||||
# Needs branch matching as it inherits .stylelintrc.js from matrix-react-sdk
|
# Does not need branch matching as only analyses this layer
|
||||||
- name: Install Dependencies
|
- name: Install Deps
|
||||||
run: "./scripts/layered.sh"
|
run: "yarn install"
|
||||||
|
|
||||||
- name: Run Linter
|
- name: Run Linter
|
||||||
run: "yarn run lint:style"
|
run: "yarn run lint:style"
|
||||||
|
|
60
.github/workflows/tests.yaml
vendored
60
.github/workflows/tests.yaml
vendored
|
@ -1,60 +0,0 @@
|
||||||
name: Tests
|
|
||||||
on:
|
|
||||||
pull_request: {}
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
merge_group:
|
|
||||||
types: [checks_requested]
|
|
||||||
repository_dispatch:
|
|
||||||
types: [element-web-notify]
|
|
||||||
env:
|
|
||||||
# These must be set for fetchdep.sh to get the right branch
|
|
||||||
REPOSITORY: ${{ github.repository }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
jobs:
|
|
||||||
jest:
|
|
||||||
name: Jest
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Yarn cache
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
cache: "yarn"
|
|
||||||
node-version: "lts/*"
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: "./scripts/layered.sh"
|
|
||||||
|
|
||||||
- name: Get number of CPU cores
|
|
||||||
id: cpu-cores
|
|
||||||
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2
|
|
||||||
|
|
||||||
- name: Run tests with coverage
|
|
||||||
run: "yarn coverage --ci --max-workers ${{ steps.cpu-cores.outputs.count }}"
|
|
||||||
|
|
||||||
- name: Upload Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: coverage
|
|
||||||
path: |
|
|
||||||
coverage
|
|
||||||
!coverage/lcov-report
|
|
||||||
|
|
||||||
skip_sonar:
|
|
||||||
name: Skip SonarCloud in merge queue
|
|
||||||
if: github.event_name == 'merge_group'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: jest
|
|
||||||
steps:
|
|
||||||
- name: Skip SonarCloud
|
|
||||||
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 }}
|
|
110
.github/workflows/tests.yml
vendored
Normal file
110
.github/workflows/tests.yml
vendored
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
name: Tests
|
||||||
|
on:
|
||||||
|
pull_request: {}
|
||||||
|
merge_group:
|
||||||
|
types: [checks_requested]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
repository_dispatch:
|
||||||
|
types: [element-web-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/layered.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
2
.gitignore
vendored
|
@ -29,3 +29,5 @@ electron/pub
|
||||||
/build_config.yaml
|
/build_config.yaml
|
||||||
/book
|
/book
|
||||||
/index.html
|
/index.html
|
||||||
|
# version file and tarball created by `npm pack` / `yarn pack`
|
||||||
|
/git-revision.txt
|
||||||
|
|
1
.node-version
Normal file
1
.node-version
Normal file
|
@ -0,0 +1 @@
|
||||||
|
20
|
|
@ -25,10 +25,17 @@ src/vector/modernizr.js
|
||||||
/docs/lib
|
/docs/lib
|
||||||
/book
|
/book
|
||||||
/debian/tmp
|
/debian/tmp
|
||||||
|
/.npmrc
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
|
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
|
||||||
/CHANGELOG.md
|
/CHANGELOG.md
|
||||||
/docs/changelogs
|
/docs/changelogs
|
||||||
|
|
||||||
|
# Legacy skinning file that some people might still have
|
||||||
|
/src/component-index.js
|
||||||
|
|
||||||
# Downloaded and already minified
|
# Downloaded and already minified
|
||||||
res/jitsi_external_api.min.js
|
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
1
.prettierrc.cjs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require("eslint-plugin-matrix-org/.prettierrc.js");
|
|
@ -1,4 +1,50 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...require("matrix-react-sdk/.stylelintrc.js"),
|
|
||||||
extends: ["stylelint-config-standard"],
|
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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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:
|
This example is for Element Web. You can specify:
|
||||||
|
|
||||||
- matrix-react-sdk
|
|
||||||
- element-web
|
- element-web
|
||||||
- element-desktop
|
- element-desktop
|
||||||
|
|
||||||
|
@ -113,14 +112,12 @@ checks, so please check back after a few minutes.
|
||||||
|
|
||||||
Your PR should include tests.
|
Your PR should include tests.
|
||||||
|
|
||||||
For new user facing features in `matrix-js-sdk`, `matrix-react-sdk` or `element-web`, you
|
For new user facing features in `matrix-js-sdk` or `element-web`, you must include:
|
||||||
must include:
|
|
||||||
|
|
||||||
1. Comprehensive unit tests written in Jest. These are located in `/test`.
|
1. Comprehensive unit tests written in Jest. These are located in `/test`.
|
||||||
2. "happy path" end-to-end tests.
|
2. "happy path" end-to-end tests.
|
||||||
These are located in `/playwright/e2e` in `matrix-react-sdk`, and
|
These are located in `/playwright/e2e`, and are run using `element-web`.
|
||||||
are run using `element-web`. Ideally, you would also include tests for edge
|
Ideally, you would also include tests for edge and error cases.
|
||||||
and error cases.
|
|
||||||
|
|
||||||
Unit tests are expected even when the feature is in labs. It's good practice
|
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
|
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.
|
exercises the area.
|
||||||
|
|
||||||
Changes to must be accompanied by unit tests written in Jest.
|
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`
|
These are located in `/spec/` in `matrix-js-sdk` or `/test/` in `element-web`.
|
||||||
and `matrix-react-sdk`.
|
|
||||||
|
|
||||||
When writing unit tests, please aim for a high level of test coverage
|
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
|
for new code - 80% or greater. If you cannot achieve that, please document
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
# Builder
|
# Builder
|
||||||
FROM --platform=$BUILDPLATFORM node:20-bullseye as builder
|
FROM --platform=$BUILDPLATFORM node:20-bullseye as builder
|
||||||
|
|
||||||
# Support custom branches of the react-sdk and js-sdk. This also helps us build
|
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
|
||||||
# images of element-web develop.
|
|
||||||
ARG USE_CUSTOM_SDKS=false
|
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_REPO="https://github.com/matrix-org/matrix-js-sdk.git"
|
||||||
ARG JS_SDK_BRANCH="master"
|
ARG JS_SDK_BRANCH="master"
|
||||||
|
|
||||||
|
|
24
README.md
24
README.md
|
@ -10,7 +10,7 @@
|
||||||
# Element
|
# Element
|
||||||
|
|
||||||
Element (formerly known as Vector and Riot) is a Matrix web client built using the [Matrix
|
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
|
# Supported Environments
|
||||||
|
|
||||||
|
@ -208,10 +208,9 @@ into Element itself.
|
||||||
|
|
||||||
# Setting up a dev environment
|
# Setting up a dev environment
|
||||||
|
|
||||||
Much of the functionality in Element is actually in the `matrix-react-sdk` and
|
Much of the functionality in Element is actually in the `matrix-js-sdk` module.
|
||||||
`matrix-js-sdk` modules. It is possible to set these up in a way that makes it
|
It is possible to set these up in a way that makes it easy to track the `develop` branches
|
||||||
easy to track the `develop` branches in git and to make local changes without
|
in git and to make local changes without having to manually rebuild each time.
|
||||||
having to manually rebuild each time.
|
|
||||||
|
|
||||||
First clone and build `matrix-js-sdk`:
|
First clone and build `matrix-js-sdk`:
|
||||||
|
|
||||||
|
@ -223,17 +222,6 @@ yarn install
|
||||||
popd
|
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:
|
Clone the repo and switch to the `element-web` directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@ -248,7 +236,6 @@ Finally, build and start Element itself:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn link matrix-js-sdk
|
yarn link matrix-js-sdk
|
||||||
yarn link matrix-react-sdk
|
|
||||||
yarn install
|
yarn install
|
||||||
yarn start
|
yarn start
|
||||||
```
|
```
|
||||||
|
@ -294,8 +281,7 @@ sudo sysctl -p
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
When you make changes to `matrix-react-sdk` or `matrix-js-sdk` they should be
|
When you make changes to `matrix-js-sdk` they should be automatically picked up by webpack and built.
|
||||||
automatically picked up by webpack and built.
|
|
||||||
|
|
||||||
If any of these steps error with, `file table overflow`, you are probably on a mac
|
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.
|
which has a very low limit on max open files. Run `ulimit -Sn 1024` and try again.
|
||||||
|
|
6
__mocks__/FontManager.js
Normal file
6
__mocks__/FontManager.js
Normal 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
2
__mocks__/empty.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Yes, this is empty.
|
||||||
|
module.exports = {};
|
1
__mocks__/imageMock.js
Normal file
1
__mocks__/imageMock.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
module.exports = "image-file-stub";
|
4
__mocks__/languages.json
Normal file
4
__mocks__/languages.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"en": "en_EN.json",
|
||||||
|
"en-us": "en_US.json"
|
||||||
|
}
|
40
__mocks__/maplibre-gl.js
Normal file
40
__mocks__/maplibre-gl.js
Normal 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
2
__mocks__/svg.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export const Icon = "div";
|
||||||
|
export default "image-file-stub";
|
11
__mocks__/workerFactoryMock.js
Normal file
11
__mocks__/workerFactoryMock.js
Normal 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;
|
||||||
|
}
|
|
@ -5,7 +5,6 @@ adjacent to. As of writing, these are:
|
||||||
|
|
||||||
- element-desktop
|
- element-desktop
|
||||||
- element-web
|
- element-web
|
||||||
- matrix-react-sdk
|
|
||||||
- matrix-js-sdk
|
- matrix-js-sdk
|
||||||
|
|
||||||
Other projects might extend this code style for increased strictness. For example, matrix-events-sdk
|
Other projects might extend this code style for increased strictness. For example, matrix-events-sdk
|
||||||
|
|
|
@ -34,8 +34,22 @@
|
||||||
- [App load order](app-load.md)
|
- [App load order](app-load.md)
|
||||||
- [Translation](translating-dev.md)
|
- [Translation](translating-dev.md)
|
||||||
- [Theming](theming.md)
|
- [Theming](theming.md)
|
||||||
|
- [Playwright end to end tests](playwright.md)
|
||||||
- [Memory profiling](memory-profiles-and-leaks.md)
|
- [Memory profiling](memory-profiles-and-leaks.md)
|
||||||
- [Jitsi](jitsi-dev.md)
|
- [Jitsi](jitsi-dev.md)
|
||||||
- [Feature flags](feature-flags.md)
|
- [Feature flags](feature-flags.md)
|
||||||
- [OIDC and delegated authentication](oidc.md)
|
- [OIDC and delegated authentication](oidc.md)
|
||||||
- [Release Process](release.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)
|
||||||
|
|
|
@ -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
|
All the issues for Element Web live in the
|
||||||
[element-web](https://github.com/element-hq/element-web) repository, including
|
[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
|
issues that actually need fixing in one of the related repos.
|
||||||
repos.
|
|
||||||
|
|
||||||
The first place to look is for
|
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).
|
[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
71
docs/ciderEditor.md
Normal 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.
|
|
@ -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
|
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
|
## Customisation & branding
|
||||||
|
|
||||||
|
|
|
@ -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.
|
easily add custom logic specific to a particular deployment of Element Web.
|
||||||
|
|
||||||
An example of this is the [security customisations
|
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:
|
This module in the React SDK only defines some empty functions and their types:
|
||||||
it does not do anything by default.
|
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 rooms,
|
||||||
- creating spaces,
|
- 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
|
`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.
|
the given UI component. When `shouldShowComponent` returns falsy all UI components for that feature will be hidden.
|
||||||
|
|
|
@ -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:
|
When starting work on a feature, we should create a matching feature flag:
|
||||||
|
|
||||||
1. Add a new
|
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:
|
of the form:
|
||||||
|
|
||||||
```js
|
```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:
|
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).
|
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)
|
3. Remove the feature from the [labs documentation](https://github.com/element-hq/element-web/blob/develop/docs/labs.md)
|
||||||
4. Celebrate! 🥳
|
4. Celebrate! 🥳
|
||||||
|
|
||||||
If the feature is meant to be forced on (non-configurable):
|
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.
|
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)
|
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
|
4. If applicable, remove the feature state from
|
||||||
|
|
6
docs/features/README.md
Normal file
6
docs/features/README.md
Normal 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
38
docs/features/composer.md
Normal 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.
|
59
docs/features/keyboardShortcuts.md
Normal file
59
docs/features/keyboardShortcuts.md
Normal 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
56
docs/icons.md
Normal 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
BIN
docs/img/RoomListStore2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 74 KiB |
|
@ -67,8 +67,6 @@ element-web branch and then run:
|
||||||
```bash
|
```bash
|
||||||
docker build -t \
|
docker build -t \
|
||||||
--build-arg USE_CUSTOM_SDKS=true \
|
--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_REPO="https://github.com/matrix-org/matrix-js-sdk.git" \
|
||||||
--build-arg JS_SDK_BRANCH="develop" \
|
--build-arg JS_SDK_BRANCH="develop" \
|
||||||
.
|
.
|
||||||
|
|
|
@ -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.
|
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).
|
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
38
docs/local-echo-dev.md
Normal 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
19
docs/media-handling.md
Normal 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.
|
|
@ -23,11 +23,11 @@ the current directory as the build context (the `.` in `docker build -t my-eleme
|
||||||
## Writing modules
|
## 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
|
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
|
exposed in the module API, the module API will need to be updated. This means a PR to both this repo
|
||||||
[`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).
|
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
|
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
|
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
|
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
219
docs/playwright.md
Normal 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.
|
|
@ -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>
|
<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.
|
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.
|
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>
|
<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 Desktop](https://github.com/element-hq/element-desktop/)
|
||||||
- [Element Web](https://github.com/element-hq/element-web/)
|
- [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/)
|
- [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:
|
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
|
subgraph Releasing
|
||||||
R1[[Releasing matrix-js-sdk]]
|
R1[[Releasing matrix-js-sdk]]
|
||||||
R2[[Releasing matrix-react-sdk]]
|
R2[[Releasing element-web]]
|
||||||
R3[[Releasing element-web]]
|
R3[[Releasing element-desktop]]
|
||||||
R4[[Releasing element-desktop]]
|
|
||||||
|
|
||||||
R1 --> R2 --> R3 --> R4
|
R1 --> R2 --> R3
|
||||||
end
|
end
|
||||||
|
|
||||||
R4 --> D1
|
R3 --> D1
|
||||||
|
|
||||||
subgraph Deploying
|
subgraph Deploying
|
||||||
D1[\Deploy staging.element.io/]
|
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**
|
- [ ] 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.
|
- [ ] 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
|
### 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)
|
- [ ] 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-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-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.
|
- [ ] 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
165
docs/room-list-store.md
Normal 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
27
docs/scrolling.md
Normal 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
236
docs/settings.md
Normal 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
18
docs/skinning.md
Normal 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.
|
|
@ -3,21 +3,17 @@
|
||||||
Themes are a very basic way of providing simple alternative look & feels to the
|
Themes are a very basic way of providing simple alternative look & feels to the
|
||||||
Element app via CSS & custom imagery.
|
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:
|
To define a theme for Element:
|
||||||
|
|
||||||
1. Pick a name, e.g. `teal`. at time of writing we have `light` and `dark`.
|
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`
|
2. Fork `res/themes/dark/css/dark.pcss` to be `teal.pcss`
|
||||||
3. Fork `src/skins/vector/css/themes/_base.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
|
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
|
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`)
|
overridden. If every single colour is being changed (as per `_dark.pcss`)
|
||||||
then you might as well keep them all.
|
then you might as well keep them all.
|
||||||
5. Add the theme to the list of entrypoints in webpack.config.js
|
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.
|
7. Sit back and admire your handywork.
|
||||||
|
|
||||||
In future, the assets for a theme will probably be gathered together into a
|
In future, the assets for a theme will probably be gathered together into a
|
||||||
|
|
|
@ -3,13 +3,12 @@
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- A working [Development Setup](../README.md#setting-up-a-dev-environment)
|
- 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
|
- Latest LTS version of Node.js installed
|
||||||
- Be able to understand English
|
- Be able to understand English
|
||||||
|
|
||||||
## Translating strings vs. marking strings for translation
|
## 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.
|
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.
|
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.
|
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
|
## 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. 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. 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.
|
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
27
docs/usercontent.md
Normal 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
61
docs/widget-layouts.md
Normal 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.
|
|
@ -16,35 +16,50 @@ const config: Config = {
|
||||||
url: "http://localhost/",
|
url: "http://localhost/",
|
||||||
},
|
},
|
||||||
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
|
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
|
||||||
setupFiles: ["jest-canvas-mock"],
|
globalSetup: "<rootDir>/test/globalSetup.ts",
|
||||||
setupFilesAfterEnv: ["<rootDir>/node_modules/matrix-react-sdk/test/setupTests.ts"],
|
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
|
||||||
|
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
"\\.(css|scss|pcss)$": "<rootDir>/__mocks__/cssMock.js",
|
"\\.(css|scss|pcss)$": "<rootDir>/__mocks__/cssMock.js",
|
||||||
"\\.(gif|png|ttf|woff2)$": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/imageMock.js",
|
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
|
||||||
"\\.svg$": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/svg.js",
|
"\\.svg$": "<rootDir>/__mocks__/svg.js",
|
||||||
"\\$webapp/i18n/languages.json": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/languages.json",
|
"\\$webapp/i18n/languages.json": "<rootDir>/__mocks__/languages.json",
|
||||||
"^react$": "<rootDir>/node_modules/react",
|
"^react$": "<rootDir>/node_modules/react",
|
||||||
"^react-dom$": "<rootDir>/node_modules/react-dom",
|
"^react-dom$": "<rootDir>/node_modules/react-dom",
|
||||||
"^matrix-js-sdk$": "<rootDir>/node_modules/matrix-js-sdk/src",
|
"^matrix-js-sdk$": "<rootDir>/node_modules/matrix-js-sdk/src",
|
||||||
"^matrix-react-sdk$": "<rootDir>/node_modules/matrix-react-sdk/src",
|
"^matrix-react-sdk$": "<rootDir>/src",
|
||||||
"decoderWorker\\.min\\.js": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js",
|
"decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
|
||||||
"decoderWorker\\.min\\.wasm": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js",
|
"decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js",
|
||||||
"waveWorker\\.min\\.js": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js",
|
"waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
|
||||||
"context-filter-polyfill": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js",
|
"context-filter-polyfill": "<rootDir>/__mocks__/empty.js",
|
||||||
"FontManager.ts": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/FontManager.js",
|
"FontManager.ts": "<rootDir>/__mocks__/FontManager.js",
|
||||||
"workers/(.+)Factory": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/workerFactoryMock.js",
|
"workers/(.+)Factory": "<rootDir>/__mocks__/workerFactoryMock.js",
|
||||||
"^!!raw-loader!.*": "jest-raw-loader",
|
"^!!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",
|
"^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"],
|
coverageReporters: ["text-summary", "lcov"],
|
||||||
testResultsProcessor: "@casualbot/jest-sonar-reporter",
|
testResultsProcessor: "@casualbot/jest-sonar-reporter",
|
||||||
|
prettierPath: null,
|
||||||
|
moduleDirectories: ["node_modules", "test/test-utils"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// if we're running under GHA, enable the GHA reporter
|
// if we're running under GHA, enable the GHA reporter
|
||||||
if (env["GITHUB_ACTIONS"] !== undefined) {
|
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;
|
export default config;
|
||||||
|
|
176
package.json
176
package.json
|
@ -32,8 +32,10 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"i18n": "matrix-gen-i18n && yarn i18n:sort && yarn i18n:lint",
|
"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: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",
|
"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",
|
"clean": "rimraf lib webapp",
|
||||||
"build": "yarn clean && yarn build:genfiles && yarn build:bundle",
|
"build": "yarn clean && yarn build:genfiles && yarn build:bundle",
|
||||||
"build-stats": "yarn clean && yarn build:genfiles && yarn build:bundle-stats",
|
"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",
|
"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": "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": "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: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": "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: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": "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:types:module_system": "tsc --noEmit --project ./tsconfig.module_system.json",
|
||||||
"lint:style": "stylelint \"res/css/**/*.pcss\"",
|
"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 \"{}\"'",
|
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
|
||||||
"test": "jest",
|
"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 -e CI -v $(pwd):/work/ -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright",
|
||||||
"coverage": "yarn test --coverage",
|
"coverage": "yarn test --coverage",
|
||||||
"analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts",
|
"analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts",
|
||||||
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
|
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
|
||||||
|
@ -68,33 +75,98 @@
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react-dom": "17.0.25",
|
"@types/react-dom": "17.0.25",
|
||||||
"@types/react": "17.0.82",
|
"@types/react": "17.0.83",
|
||||||
|
"@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-design-tokens": "1.8.0",
|
||||||
"@vector-im/compound-web": "7.0.0",
|
"@vector-im/compound-web": "7.0.0",
|
||||||
"@floating-ui/react": "0.26.24",
|
"@floating-ui/react": "0.26.11",
|
||||||
"@radix-ui/react-id": "1.1.0"
|
"@radix-ui/react-id": "1.1.0",
|
||||||
|
"caniuse-lite": "1.0.30001668",
|
||||||
|
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
|
||||||
|
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
"@formatjs/intl-segmenter": "^11.5.7",
|
"@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-design-tokens": "^1.8.0",
|
||||||
"@vector-im/compound-web": "^7.0.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",
|
"jsrsasign": "^11.0.0",
|
||||||
|
"js-xxhash": "^4.0.0",
|
||||||
|
"jszip": "^3.7.0",
|
||||||
"katex": "^0.16.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",
|
"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-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||||
"matrix-react-sdk": "github:matrix-org/matrix-react-sdk#develop",
|
"matrix-widget-api": "^1.9.0",
|
||||||
"matrix-widget-api": "^1.8.2",
|
"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": "17.0.2",
|
||||||
|
"react-beautiful-dnd": "^13.1.0",
|
||||||
|
"react-blurhash": "^0.3.0",
|
||||||
"react-dom": "17.0.2",
|
"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": {
|
"devDependencies": {
|
||||||
"@action-validator/cli": "^0.6.0",
|
"@action-validator/cli": "^0.6.0",
|
||||||
"@action-validator/core": "^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/core": "^7.12.10",
|
||||||
"@babel/eslint-parser": "^7.12.10",
|
"@babel/eslint-parser": "^7.12.10",
|
||||||
"@babel/eslint-plugin": "^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-proposal-export-default-from": "^7.12.1",
|
||||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||||
"@babel/plugin-transform-class-properties": "^7.12.1",
|
"@babel/plugin-transform-class-properties": "^7.12.1",
|
||||||
|
@ -109,48 +181,60 @@
|
||||||
"@babel/preset-typescript": "^7.12.7",
|
"@babel/preset-typescript": "^7.12.7",
|
||||||
"@babel/register": "^7.12.10",
|
"@babel/register": "^7.12.10",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@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",
|
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||||
"@sentry/webpack-plugin": "^2.7.1",
|
"@sentry/webpack-plugin": "^2.7.1",
|
||||||
"@svgr/webpack": "^8.0.0",
|
"@svgr/webpack": "^8.0.0",
|
||||||
|
"@testing-library/dom": "^9.0.0",
|
||||||
|
"@testing-library/jest-dom": "^6.0.0",
|
||||||
"@testing-library/react": "^12.1.5",
|
"@testing-library/react": "^12.1.5",
|
||||||
"@types/commonmark": "^0.27.9",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"@types/content-type": "^1.1.8",
|
"@types/commonmark": "^0.27.4",
|
||||||
"@types/counterpart": "^0.18.4",
|
"@types/content-type": "^1.1.5",
|
||||||
"@types/diff-match-patch": "^1.0.36",
|
"@types/counterpart": "^0.18.1",
|
||||||
"@types/escape-html": "^1.0.4",
|
"@types/css-tree": "^2.3.8",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/diff-match-patch": "^1.0.32",
|
||||||
"@types/glob-to-regexp": "^0.4.4",
|
"@types/escape-html": "^1.0.1",
|
||||||
"@types/jest": "^29.0.0",
|
"@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/jitsi-meet": "^2.0.2",
|
||||||
"@types/jsrsasign": "^10.5.4",
|
"@types/jsrsasign": "^10.5.4",
|
||||||
"@types/katex": "^0.16.7",
|
"@types/katex": "^0.16.0",
|
||||||
"@types/lodash": "^4.14.197",
|
"@types/lodash": "^4.14.168",
|
||||||
"@types/minimist": "^1.2.5",
|
"@types/minimist": "^1.2.5",
|
||||||
"@types/modernizr": "^3.5.6",
|
"@types/modernizr": "^3.5.3",
|
||||||
"@types/node": "^16",
|
"@types/node": "18",
|
||||||
"@types/node-fetch": "^2.6.4",
|
"@types/node-fetch": "^2.6.2",
|
||||||
"@types/pako": "^2.0.3",
|
"@types/pako": "^2.0.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.3.5",
|
||||||
"@types/react": "17.0.82",
|
"@types/react": "17.0.83",
|
||||||
"@types/react-beautiful-dnd": "^13.1.7",
|
"@types/react-beautiful-dnd": "^13.0.0",
|
||||||
"@types/react-dom": "17.0.25",
|
"@types/react-dom": "17.0.25",
|
||||||
"@types/react-transition-group": "^4.4.9",
|
"@types/react-transition-group": "^4.4.0",
|
||||||
"@types/sanitize-html": "^2.9.5",
|
"@types/sanitize-html": "2.13.0",
|
||||||
"@types/sdp-transform": "^2.4.9",
|
"@types/sdp-transform": "^2.4.6",
|
||||||
|
"@types/seedrandom": "3.0.8",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
"@types/tar-js": "^0.3.5",
|
"@types/tar-js": "^0.3.5",
|
||||||
"@types/ua-parser-js": "^0.7.36",
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
|
"axe-core": "4.10.0",
|
||||||
"babel-jest": "^29.0.0",
|
"babel-jest": "^29.0.0",
|
||||||
"babel-loader": "^9.0.0",
|
"babel-loader": "^9.0.0",
|
||||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||||
|
"blob-polyfill": "^9.0.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
"copy-webpack-plugin": "^12.0.0",
|
"copy-webpack-plugin": "^12.0.0",
|
||||||
|
"core-js": "^3.38.1",
|
||||||
"cronstrue": "^2.41.0",
|
"cronstrue": "^2.41.0",
|
||||||
"css-loader": "^7.0.0",
|
"css-loader": "^7.0.0",
|
||||||
"css-minimizer-webpack-plugin": "^7.0.0",
|
"css-minimizer-webpack-plugin": "^7.0.0",
|
||||||
|
@ -159,30 +243,39 @@
|
||||||
"eslint-config-google": "^0.14.0",
|
"eslint-config-google": "^0.14.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-deprecate": "0.8.5",
|
"eslint-plugin-deprecate": "0.8.5",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-import": "^2.25.4",
|
||||||
"eslint-plugin-matrix-org": "^1.0.0",
|
"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": "^7.28.0",
|
||||||
"eslint-plugin-react-hooks": "^4.3.0",
|
"eslint-plugin-react-hooks": "^4.3.0",
|
||||||
"eslint-plugin-unicorn": "^55.0.0",
|
"eslint-plugin-unicorn": "^56.0.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
"fake-indexeddb": "^6.0.0",
|
"fake-indexeddb": "^6.0.0",
|
||||||
"fetch-mock": "9.11.0",
|
"fetch-mock": "9.11.0",
|
||||||
"fetch-mock-jest": "^1.5.1",
|
"fetch-mock-jest": "^1.5.1",
|
||||||
"file-loader": "^6.0.0",
|
"file-loader": "^6.0.0",
|
||||||
|
"fs-extra": "^11.0.0",
|
||||||
|
"glob": "^11.0.0",
|
||||||
"html-webpack-plugin": "^5.5.3",
|
"html-webpack-plugin": "^5.5.3",
|
||||||
"husky": "^9.0.0",
|
"husky": "^9.0.0",
|
||||||
"jest": "^29.0.0",
|
"jest": "^29.6.2",
|
||||||
"jest-canvas-mock": "2.5.2",
|
"jest-canvas-mock": "^2.5.2",
|
||||||
"jest-environment-jsdom": "^29.0.0",
|
"jest-environment-jsdom": "^29.6.2",
|
||||||
"jest-mock": "^29.0.0",
|
"jest-mock": "^29.6.2",
|
||||||
"jest-raw-loader": "^1.0.1",
|
"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-mock-request": "^2.5.0",
|
||||||
"matrix-web-i18n": "^3.2.1",
|
"matrix-web-i18n": "^3.2.1",
|
||||||
"mini-css-extract-plugin": "2.9.0",
|
"mini-css-extract-plugin": "2.9.0",
|
||||||
"minimist": "^1.2.6",
|
"minimist": "^1.2.6",
|
||||||
"mkdirp": "^3.0.0",
|
"mkdirp": "^3.0.0",
|
||||||
|
"mocha-junit-reporter": "^2.2.0",
|
||||||
"modernizr": "^3.12.0",
|
"modernizr": "^3.12.0",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
|
"playwright-core": "^1.45.1",
|
||||||
"postcss": "8.4.38",
|
"postcss": "8.4.38",
|
||||||
"postcss-easings": "^4.0.0",
|
"postcss-easings": "^4.0.0",
|
||||||
"postcss-hexrgba": "2.1.0",
|
"postcss-hexrgba": "2.1.0",
|
||||||
|
@ -204,8 +297,9 @@
|
||||||
"terser-webpack-plugin": "^5.3.9",
|
"terser-webpack-plugin": "^5.3.9",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"ts-prune": "^0.10.3",
|
"ts-prune": "^0.10.3",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.3",
|
||||||
"util": "^0.12.5",
|
"util": "^0.12.5",
|
||||||
|
"web-streams-polyfill": "^4.0.0",
|
||||||
"webpack": "^5.89.0",
|
"webpack": "^5.89.0",
|
||||||
"webpack-bundle-analyzer": "^4.8.0",
|
"webpack-bundle-analyzer": "^4.8.0",
|
||||||
"webpack-cli": "^5.0.0",
|
"webpack-cli": "^5.0.0",
|
||||||
|
|
38
playwright.config.ts
Normal file
38
playwright.config.ts
Normal 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 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
6
playwright/.gitignore
vendored
Normal 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
12
playwright/@types/playwright-core.d.ts
vendored
Normal 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
9
playwright/Dockerfile
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.48.0-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"]
|
5
playwright/docker-entrypoint.sh
Normal file
5
playwright/docker-entrypoint.sh
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
npx playwright test --update-snapshots --reporter line $@
|
158
playwright/e2e/accessibility/keyboard-navigation.spec.ts
Normal file
158
playwright/e2e/accessibility/keyboard-navigation.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
34
playwright/e2e/app-loading/feature-detection.spec.ts
Normal file
34
playwright/e2e/app-loading/feature-detection.spec.ts
Normal 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");
|
||||||
|
});
|
37
playwright/e2e/app-loading/guest-registration.spec.ts
Normal file
37
playwright/e2e/app-loading/guest-registration.spec.ts
Normal 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();
|
||||||
|
});
|
60
playwright/e2e/app-loading/stored-credentials.spec.ts
Normal file
60
playwright/e2e/app-loading/stored-credentials.spec.ts
Normal 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();
|
||||||
|
});
|
347
playwright/e2e/audio-player/audio-player.spec.ts
Normal file
347
playwright/e2e/audio-player/audio-player.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
128
playwright/e2e/chat-export/html-export.spec.ts
Normal file
128
playwright/e2e/chat-export/html-export.spec.ts
Normal 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"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
98
playwright/e2e/composer/CIDER.spec.ts
Normal file
98
playwright/e2e/composer/CIDER.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
350
playwright/e2e/composer/RTE.spec.ts
Normal file
350
playwright/e2e/composer/RTE.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
34
playwright/e2e/create-room/create-room.spec.ts
Normal file
34
playwright/e2e/create-room/create-room.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
112
playwright/e2e/crypto/backups.spec.ts
Normal file
112
playwright/e2e/crypto/backups.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
27
playwright/e2e/crypto/complete-security.spec.ts
Normal file
27
playwright/e2e/crypto/complete-security.spec.ts
Normal 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`.
|
||||||
|
});
|
248
playwright/e2e/crypto/crypto.spec.ts
Normal file
248
playwright/e2e/crypto/crypto.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
294
playwright/e2e/crypto/decryption-failure-messages.spec.ts
Normal file
294
playwright/e2e/crypto/decryption-failure-messages.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
106
playwright/e2e/crypto/dehydration.spec.ts
Normal file
106
playwright/e2e/crypto/dehydration.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
259
playwright/e2e/crypto/device-verification.spec.ts
Normal file
259
playwright/e2e/crypto/device-verification.spec.ts
Normal 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);
|
||||||
|
}
|
311
playwright/e2e/crypto/event-shields.spec.ts
Normal file
311
playwright/e2e/crypto/event-shields.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
56
playwright/e2e/crypto/invisible-crypto.spec.ts
Normal file
56
playwright/e2e/crypto/invisible-crypto.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
60
playwright/e2e/crypto/logout.spec.ts
Normal file
60
playwright/e2e/crypto/logout.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
64
playwright/e2e/crypto/migration.spec.ts
Normal file
64
playwright/e2e/crypto/migration.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
|
@ -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`.
|
71732
playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json
Normal file
71732
playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,6 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="load.js"></script>
|
||||||
|
</head>
|
||||||
|
Loading test data...
|
||||||
|
</html>
|
220
playwright/e2e/crypto/test_indexeddb_cryptostore_dump/load.js
Normal file
220
playwright/e2e/crypto/test_indexeddb_cryptostore_dump/load.js
Normal 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();
|
137
playwright/e2e/crypto/user-verification.spec.ts
Normal file
137
playwright/e2e/crypto/user-verification.spec.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
391
playwright/e2e/crypto/utils.ts
Normal file
391
playwright/e2e/crypto/utils.ts
Normal file
|
@ -0,0 +1,391 @@
|
||||||
|
/*
|
||||||
|
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 { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
import type {
|
||||||
|
CryptoEvent,
|
||||||
|
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;
|
||||||
|
}
|
366
playwright/e2e/editing/editing.spec.ts
Normal file
366
playwright/e2e/editing/editing.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
37
playwright/e2e/file-upload/image-upload.spec.ts
Normal file
37
playwright/e2e/file-upload/image-upload.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
69
playwright/e2e/forgot-password/forgot-password.spec.ts
Normal file
69
playwright/e2e/forgot-password/forgot-password.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
120
playwright/e2e/integration-manager/get-openid-token.spec.ts
Normal file
120
playwright/e2e/integration-manager/get-openid-token.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue