Compare commits
70 Commits
t3chguy/re
...
dbkr/playw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c060ec7051 | ||
|
|
bd3e93e8dd | ||
|
|
0555701829 | ||
|
|
417db4c9b2 | ||
|
|
4e151f8d03 | ||
|
|
afa7ec695d | ||
|
|
e98529824e | ||
|
|
16d2cccb73 | ||
|
|
9d5141cfaa | ||
|
|
1e42f28a69 | ||
|
|
4e1bd69e4d | ||
|
|
12943954c6 | ||
|
|
ec95435724 | ||
|
|
7e1927d388 | ||
|
|
0b24d33c64 | ||
|
|
db02f26005 | ||
|
|
b07d10cb23 | ||
|
|
179b17434e | ||
|
|
ab401160f8 | ||
|
|
5448de5dd6 | ||
|
|
be181d2c79 | ||
|
|
baaed75c4b | ||
|
|
cd7cf86b96 | ||
|
|
2c4a079153 | ||
|
|
9099338af8 | ||
|
|
f621c342ff | ||
|
|
4c1924311f | ||
|
|
a7e3764c27 | ||
|
|
07f1680ba0 | ||
|
|
3fbc9e6de6 | ||
|
|
117bee787f | ||
|
|
580213da5d | ||
|
|
22530d6ea5 | ||
|
|
e7d9df24e2 | ||
|
|
95c879c9e5 | ||
|
|
cbc1838755 | ||
|
|
c2799a1812 | ||
|
|
980b922348 | ||
|
|
ad77f7943b | ||
|
|
89d7dca464 | ||
|
|
aa44cadb02 | ||
|
|
941f4e1005 | ||
|
|
9b85c2d0fd | ||
|
|
1e0dfd0241 | ||
|
|
bea1b8eb85 | ||
|
|
d5db16ca24 | ||
|
|
edaf9773c0 | ||
|
|
7ea188cf89 | ||
|
|
a581e776a8 | ||
|
|
8d261d9819 | ||
|
|
299270e52d | ||
|
|
943b817194 | ||
|
|
2aa72bb40b | ||
|
|
a755e399cf | ||
|
|
8dff758153 | ||
|
|
cf3bdbdc7a | ||
|
|
ba98c2085d | ||
|
|
b330de5d6e | ||
|
|
b86bb5cc2f | ||
|
|
e835cab139 | ||
|
|
af3040fb62 | ||
|
|
b6ba3335ec | ||
|
|
6b7c94905f | ||
|
|
a4e8bb3f9a | ||
|
|
2b4000d47f | ||
|
|
01304439ee | ||
|
|
c659afa8db | ||
|
|
9cc5564d50 | ||
|
|
549300726f | ||
|
|
319dab5920 |
1
.github/CODEOWNERS
vendored
@@ -13,6 +13,7 @@
|
||||
|
||||
# Ignore translations as those will be updated by GHA for Localazy download
|
||||
/src/i18n/strings
|
||||
/src/i18n/strings/en_EN.json @element-hq/element-web-reviewers
|
||||
# Ignore the synapse plugin as this is updated by GHA for docker image updating
|
||||
/playwright/plugins/homeserver/synapse/index.ts
|
||||
|
||||
|
||||
6
.github/workflows/deploy.yml
vendored
@@ -16,6 +16,11 @@ on:
|
||||
options:
|
||||
- staging.element.io
|
||||
- app.element.io
|
||||
skip-checks:
|
||||
description: Skip CI on the tagged commit
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
concurrency: ${{ inputs.site || 'staging.element.io' }}
|
||||
permissions: {}
|
||||
jobs:
|
||||
@@ -75,6 +80,7 @@ jobs:
|
||||
|
||||
- name: Wait for other steps to succeed
|
||||
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork
|
||||
if: inputs.skip-checks != true
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
running-workflow-name: "Deploy to Cloudflare Pages"
|
||||
|
||||
49
.github/workflows/end-to-end-tests.yaml
vendored
@@ -3,6 +3,9 @@
|
||||
# as an artifact and run end-to-end tests.
|
||||
name: End to End Tests
|
||||
on:
|
||||
# CRON to run all Projects at 6am UTC
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
pull_request: {}
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
@@ -32,6 +35,8 @@ concurrency:
|
||||
env:
|
||||
# fetchdep.sh needs to know our PR number
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
# Use 6 runners in the default case, but 4 when running on a schedule where we run all 5 projects (20 runners total)
|
||||
NUM_RUNNERS: ${{ github.event_name == 'schedule' && 4 || 6 }}
|
||||
|
||||
permissions: {} # No permissions required
|
||||
|
||||
@@ -40,6 +45,9 @@ jobs:
|
||||
name: "Build Element-Web"
|
||||
runs-on: ubuntu-24.04
|
||||
if: inputs.skip != true
|
||||
outputs:
|
||||
num-runners: ${{ env.NUM_RUNNERS }}
|
||||
runners-matrix: ${{ steps.runner-vars.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -79,8 +87,17 @@ jobs:
|
||||
path: webapp
|
||||
retention-days: 1
|
||||
|
||||
- name: Calculate runner variables
|
||||
id: runner-vars
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const numRunners = parseInt(process.env.NUM_RUNNERS, 10);
|
||||
const matrix = Array.from({ length: numRunners }, (_, i) => i + 1);
|
||||
core.setOutput("matrix", JSON.stringify(matrix));
|
||||
|
||||
playwright:
|
||||
name: "Run Tests ${{ matrix.runner }}/${{ strategy.job-total }}"
|
||||
name: "Run Tests [${{ matrix.project }}] ${{ matrix.runner }}/${{ needs.build.outputs.num-runners }}"
|
||||
needs: build
|
||||
if: inputs.skip != true
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -92,7 +109,19 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Run multiple instances in parallel to speed up the tests
|
||||
runner: [1, 2, 3, 4, 5, 6]
|
||||
runner: ${{ fromJSON(needs.build.outputs.runners-matrix) }}
|
||||
project:
|
||||
- Chrome
|
||||
- Firefox
|
||||
- WebKit
|
||||
isCron:
|
||||
- ${{ github.event_name == 'schedule' }}
|
||||
# Skip the Firefox & Safari runs unless this was a cron trigger
|
||||
exclude:
|
||||
- isCron: false
|
||||
project: Firefox
|
||||
- isCron: false
|
||||
project: WebKit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -124,24 +153,30 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}-chromium
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}
|
||||
|
||||
- name: Install Playwright browser
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: yarn playwright install --with-deps --no-shell chromium
|
||||
run: yarn playwright install --with-deps --no-shell
|
||||
|
||||
- name: Install system dependencies for WebKit
|
||||
# Some WebKit dependencies seem to lay outside the cache and will need to be installed separately
|
||||
if: matrix.project == 'WebKit' && steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: yarn playwright install-deps webkit
|
||||
|
||||
# We skip tests tagged with @mergequeue when running on PRs, but run them in MQ and everywhere else
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
yarn playwright test \
|
||||
--shard "${{ matrix.runner }}/${{ strategy.job-total }}" \
|
||||
--shard "${{ matrix.runner }}/${{ needs.build.outputs.num-runners }}" \
|
||||
--project="${{ matrix.project }}" \
|
||||
${{ github.event_name == 'pull_request' && '--grep-invert @mergequeue' || '' }}
|
||||
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: all-blob-reports-${{ matrix.runner }}
|
||||
name: all-blob-reports-${{ matrix.project }}-${{ matrix.runner }}
|
||||
path: blob-report
|
||||
retention-days: 1
|
||||
|
||||
|
||||
3
.github/workflows/localazy_download.yaml
vendored
@@ -3,7 +3,8 @@ on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "0 6 * * 1,3,5" # Every Monday, Wednesday and Friday at 6am UTC
|
||||
permissions: {} # We use ELEMENT_BOT_TOKEN instead
|
||||
permissions:
|
||||
pull-requests: write # needed to auto-approve PRs
|
||||
jobs:
|
||||
download:
|
||||
uses: matrix-org/matrix-web-i18n/.github/workflows/localazy_download.yaml@main
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Skip SonarCloud in merge queue
|
||||
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
|
||||
uses: guibranco/github-status-action-v2@66088c44e212a906c32a047529a213d81809ec1c
|
||||
uses: guibranco/github-status-action-v2@d469d49426f5a7b8a1fbcac20ad274d3e4892321
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
35
CHANGELOG.md
@@ -1,3 +1,38 @@
|
||||
Changes in [1.11.89](https://github.com/element-hq/element-web/releases/tag/v1.11.89) (2024-12-18)
|
||||
==================================================================================================
|
||||
This is a patch release to fix a bug which could prevent loading stored crypto state from storage, and also to fix URL previews when switching back to a room.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Upgrade matrix-sdk-crypto-wasm to 1.11.0 (https://github.com/matrix-org/matrix-js-sdk/pull/4593)
|
||||
* Fix url preview display ([#28766](https://github.com/element-hq/element-web/pull/28766)).
|
||||
|
||||
|
||||
Changes in [1.11.88](https://github.com/element-hq/element-web/releases/tag/v1.11.88) (2024-12-17)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Allow trusted Element Call widget to send and receive media encryption key to-device messages ([#28316](https://github.com/element-hq/element-web/pull/28316)). Contributed by @hughns.
|
||||
* increase ringing timeout from 10 seconds to 90 seconds ([#28630](https://github.com/element-hq/element-web/pull/28630)). Contributed by @fkwp.
|
||||
* Add `Close` tooltip to dialog ([#28617](https://github.com/element-hq/element-web/pull/28617)). Contributed by @florianduros.
|
||||
* New UX for Share dialog ([#28598](https://github.com/element-hq/element-web/pull/28598)). Contributed by @florianduros.
|
||||
* Improve performance of RoomContext in RoomHeader ([#28574](https://github.com/element-hq/element-web/pull/28574)). Contributed by @t3chguy.
|
||||
* Remove `Features.RustCrypto` flag ([#28582](https://github.com/element-hq/element-web/pull/28582)). Contributed by @florianduros.
|
||||
* Add Modernizr warning when running in non-secure context ([#28581](https://github.com/element-hq/element-web/pull/28581)). Contributed by @t3chguy.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix jumpy timeline when the pinned message banner is displayed ([#28654](https://github.com/element-hq/element-web/pull/28654)). Contributed by @florianduros.
|
||||
* Fix font \& spaces in settings subsection ([#28631](https://github.com/element-hq/element-web/pull/28631)). Contributed by @florianduros.
|
||||
* Remove manual device verification which is not supported by the new cryptography stack ([#28588](https://github.com/element-hq/element-web/pull/28588)). Contributed by @florianduros.
|
||||
* Fix code block highlighting not working reliably with many code blocks ([#28613](https://github.com/element-hq/element-web/pull/28613)). Contributed by @t3chguy.
|
||||
* Remove remaining reply fallbacks code ([#28610](https://github.com/element-hq/element-web/pull/28610)). Contributed by @t3chguy.
|
||||
* Provide a way to activate GIFs via the keyboard for a11y ([#28611](https://github.com/element-hq/element-web/pull/28611)). Contributed by @t3chguy.
|
||||
* Fix format bar position ([#28591](https://github.com/element-hq/element-web/pull/28591)). Contributed by @florianduros.
|
||||
* Fix room taking long time to load ([#28579](https://github.com/element-hq/element-web/pull/28579)). Contributed by @florianduros.
|
||||
* Show the correct shield status in tooltip for more conditions ([#28476](https://github.com/element-hq/element-web/pull/28476)). Contributed by @uhoreg.
|
||||
|
||||
|
||||
Changes in [1.11.87](https://github.com/element-hq/element-web/releases/tag/v1.11.87) (2024-12-03)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -6,15 +6,12 @@ ARG USE_CUSTOM_SDKS=false
|
||||
ARG JS_SDK_REPO="https://github.com/matrix-org/matrix-js-sdk.git"
|
||||
ARG JS_SDK_BRANCH="master"
|
||||
|
||||
RUN apt-get update && apt-get install -y git dos2unix
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY . /src
|
||||
RUN dos2unix /src/scripts/docker-link-repos.sh && bash /src/scripts/docker-link-repos.sh
|
||||
RUN /src/scripts/docker-link-repos.sh
|
||||
RUN yarn --network-timeout=200000 install
|
||||
|
||||
RUN dos2unix /src/scripts/docker-package.sh /src/scripts/get-version-from-git.sh /src/scripts/normalize-version.sh && bash /src/scripts/docker-package.sh
|
||||
RUN /src/scripts/docker-package.sh
|
||||
|
||||
# Copy the config now so that we don't create another layer in the app image
|
||||
RUN cp /src/config.sample.json /src/webapp/config.json
|
||||
|
||||
22
docs/oidc.md
@@ -1,29 +1,9 @@
|
||||
# OIDC and delegated authentication
|
||||
|
||||
## Compatibility/OIDC-aware mode
|
||||
|
||||
[MSC2965: OIDC provider discovery](https://github.com/matrix-org/matrix-spec-proposals/pull/2965)
|
||||
[MSC3824: OIDC aware clients](https://github.com/matrix-org/matrix-spec-proposals/pull/3824)
|
||||
This mode uses an SSO flow to gain a `loginToken` from the authentication provider, then continues with SSO login.
|
||||
Element Web uses [MSC2965: OIDC provider discovery](https://github.com/matrix-org/matrix-spec-proposals/pull/2965) to discover the configured provider.
|
||||
Wherever valid MSC2965 configuration is discovered, OIDC-aware login flow will be the only option offered.
|
||||
|
||||
## (🧪Experimental) OIDC-native flow
|
||||
|
||||
Can be enabled by a config-level-only setting in `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"feature_oidc_native_flow": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See https://areweoidcyet.com/client-implementation-guide/ for implementation details.
|
||||
|
||||
Element Web uses [MSC2965: OIDC provider discovery](https://github.com/matrix-org/matrix-spec-proposals/pull/2965) to discover the configured provider.
|
||||
Where OIDC native login flow is enabled and valid MSC2965 configuration is discovered, OIDC native login flow will be the only login option offered.
|
||||
Where a valid MSC2965 configuration is discovered, OIDC native login flow will be the only login option offered.
|
||||
Element Web will attempt to [dynamically register](https://openid.net/specs/openid-connect-registration-1_0.html) with the configured OP.
|
||||
Then, authentication will be completed [as described here](https://areweoidcyet.com/client-implementation-guide/).
|
||||
|
||||
|
||||
@@ -53,15 +53,11 @@ yarn run test:playwright:open --headed --debug
|
||||
|
||||
See more command line options at <https://playwright.dev/docs/test-cli>.
|
||||
|
||||
### Running with Rust cryptography
|
||||
## Projects
|
||||
|
||||
`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.
|
||||
By default, Playwright will run all "Projects", this means tests will run against Chrome, Firefox and "Safari" (Webkit).
|
||||
We only run tests against Chrome in pull request CI, but all projects in the merge queue.
|
||||
Some tests are excluded from running on certain browsers due to incompatibilities in the test harness.
|
||||
|
||||
## How the Tests Work
|
||||
|
||||
@@ -224,3 +220,14 @@ We use test tags to categorise tests for running subsets more efficiently.
|
||||
|
||||
- `@mergequeue`: Tests that are slow or flaky and cover areas of the app we update seldom, should not be run on every PR commit but will be run in the Merge Queue.
|
||||
- `@screenshot`: Tests that use `toMatchScreenshot` to speed up a run of `test:playwright:screenshots`. A test with this tag must not also have the `@mergequeue` tag as this would cause false positives in the stale screenshot detection.
|
||||
- `@no-$project`: Tests which are unsupported in $Project. These tests will be skipped when running in $Project.
|
||||
|
||||
Anything testing Matrix media will need to have `@no-firefox` and `@no-webkit` as those rely on the service worker which
|
||||
has to be disabled in Playwright on Firefox & Webkit to retain routing functionality.
|
||||
Anything testing VoIP/microphone will need to have `@no-webkit` as fake microphone functionality is not available
|
||||
there at this time.
|
||||
|
||||
## Colima
|
||||
|
||||
If you are running under Colima, you may need to set the environment variable `TMPDIR` to `/tmp/colima` or a path
|
||||
within `$HOME` to allow bind mounting temporary directories into the Docker containers.
|
||||
|
||||
@@ -14,6 +14,8 @@ const config: Config = {
|
||||
testEnvironment: "jsdom",
|
||||
testEnvironmentOptions: {
|
||||
url: "http://localhost/",
|
||||
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
|
||||
customExportConditions: ["browser", "node"],
|
||||
},
|
||||
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
|
||||
globalSetup: "<rootDir>/test/globalSetup.ts",
|
||||
|
||||
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.11.87",
|
||||
"version": "1.11.89",
|
||||
"description": "A feature-rich client for Matrix.org",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"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 $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright --grep @screenshot",
|
||||
"test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -e CI -v $(pwd):/work/ -v $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright --grep @screenshot --project=Chrome",
|
||||
"coverage": "yarn test --coverage",
|
||||
"analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts",
|
||||
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
|
||||
@@ -87,9 +87,10 @@
|
||||
"@matrix-org/react-sdk-module-api": "^2.4.0",
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@sentry/browser": "^8.0.0",
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@vector-im/compound-design-tokens": "^2.0.1",
|
||||
"@vector-im/compound-web": "^7.4.0",
|
||||
"@vector-im/matrix-wysiwyg": "2.37.13",
|
||||
"@vector-im/compound-web": "^7.5.0",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.0",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||
@@ -269,7 +270,7 @@
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"postcss-scss": "^4.0.4",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "3.4.1",
|
||||
"prettier": "3.4.2",
|
||||
"process": "^0.11.10",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^6.0.0",
|
||||
@@ -282,7 +283,7 @@
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-prune": "^0.10.3",
|
||||
"typescript": "5.6.3",
|
||||
"typescript": "5.7.2",
|
||||
"util": "^0.12.5",
|
||||
"web-streams-polyfill": "^4.0.0",
|
||||
"webpack": "^5.89.0",
|
||||
|
||||
@@ -11,16 +11,49 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080";
|
||||
|
||||
export default defineConfig({
|
||||
projects: [{ name: "Chrome", use: { ...devices["Desktop Chrome"], channel: "chromium" } }],
|
||||
projects: [
|
||||
{
|
||||
name: "Chrome",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
channel: "chromium",
|
||||
permissions: ["clipboard-write", "clipboard-read", "microphone"],
|
||||
launchOptions: {
|
||||
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
launchOptions: {
|
||||
firefoxUserPrefs: {
|
||||
"permissions.default.microphone": 1,
|
||||
},
|
||||
},
|
||||
// This is needed to work around an issue between Playwright routes, Firefox, and Service workers
|
||||
// https://github.com/microsoft/playwright/issues/33561#issuecomment-2471642120
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
ignoreSnapshots: true,
|
||||
},
|
||||
{
|
||||
name: "WebKit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
// Seemingly WebKit has the same issue as Firefox in Playwright routes not working
|
||||
// https://playwright.dev/docs/network#missing-network-events-and-service-workers
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
ignoreSnapshots: true,
|
||||
},
|
||||
],
|
||||
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: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.49.0-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.49.1-noble
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import { Layout } from "../../../src/settings/enums/Layout";
|
||||
import { ElementAppPage } from "../../pages/ElementAppPage";
|
||||
|
||||
test.describe("Audio player", () => {
|
||||
test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
test.use({
|
||||
displayName: "Hanako",
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { test as masTest, registerAccountMas } from "../oidc";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { TestClientServerAPI } from "../csAPI";
|
||||
|
||||
async function expectBackupVersionToBe(page: Page, version: string) {
|
||||
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
|
||||
@@ -18,95 +21,181 @@ async function expectBackupVersionToBe(page: Page, version: string) {
|
||||
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
|
||||
}
|
||||
|
||||
// These tests register an account with MAS because then we go through the "normal" registration flow
|
||||
// and crypto gets set up. Using the 'user' fixture create a a user an synthesizes an existing login,
|
||||
// which is faster but leaves us without crypto set up.
|
||||
masTest.describe("Encryption state after registration", () => {
|
||||
masTest.skip(isDendrite, "does not yet support MAS");
|
||||
|
||||
masTest("Key backup is enabled by default", async ({ page, mailhog, app }) => {
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
expect(page.getByText("This session is backing up your keys.")).toBeVisible();
|
||||
});
|
||||
|
||||
masTest("user is prompted to set up recovery", async ({ page, mailhog, app }) => {
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
await page.getByRole("button", { name: "Add room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("test room");
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
masTest.describe("Key backup reset from elsewhere", () => {
|
||||
masTest.skip(isDendrite, "does not yet support MAS");
|
||||
|
||||
masTest(
|
||||
"Key backup is disabled when reset from elsewhere",
|
||||
async ({ page, mailhog, request, masPrepare, homeserver }) => {
|
||||
const testUsername = "alice";
|
||||
const testPassword = "Pa$sW0rD!";
|
||||
|
||||
// there's a delay before keys are uploaded so the error doesn't appear immediately: use a fake
|
||||
// clock so we can skip the delay
|
||||
await page.clock.install();
|
||||
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailhog.api, testUsername, "alice@email.com", testPassword);
|
||||
|
||||
await page.getByRole("button", { name: "Add room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("test room");
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
|
||||
// @ts-ignore - this runs in the browser scope where mxMatrixClientPeg is a thing. Here, it is not.
|
||||
const accessToken = await page.evaluate(() => mxMatrixClientPeg.get().getAccessToken());
|
||||
|
||||
const csAPI = new TestClientServerAPI(request, homeserver, accessToken);
|
||||
|
||||
const backupInfo = await csAPI.getCurrentBackupInfo();
|
||||
|
||||
await csAPI.deleteBackupVersion(backupInfo.version);
|
||||
|
||||
await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("/discardsession");
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
await page
|
||||
.getByRole("textbox", { name: "Send an encrypted message…" })
|
||||
.fill("Message with broken key backup");
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
// Should be the message we sent plus the room creation event
|
||||
await expect(page.locator(".mx_EventTile")).toHaveCount(2);
|
||||
await expect(
|
||||
page.locator(".mx_RoomView_MessageList > .mx_EventTile_last .mx_EventTile_receiptSent"),
|
||||
).toBeVisible();
|
||||
|
||||
// Wait for it to try uploading the key
|
||||
await page.clock.fastForward(20000);
|
||||
|
||||
await expect(page.getByRole("heading", { level: 1, name: "New Recovery Method" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("Backups", () => {
|
||||
test.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");
|
||||
test(
|
||||
"Create, delete and recreate a keys backup",
|
||||
{ tag: "@no-webkit" },
|
||||
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();
|
||||
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");
|
||||
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();
|
||||
// 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();
|
||||
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();
|
||||
// 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();
|
||||
// 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 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"
|
||||
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();
|
||||
// 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();
|
||||
// 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();
|
||||
// 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();
|
||||
// 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");
|
||||
await expectBackupVersionToBe(page, "2");
|
||||
|
||||
// ==
|
||||
// Ensure that if you don't have the secret storage passphrase the backup won't be created
|
||||
// ==
|
||||
// ==
|
||||
// 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();
|
||||
// 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();
|
||||
// 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();
|
||||
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();
|
||||
});
|
||||
// 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ test.describe("Cryptography", function () {
|
||||
* 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) {
|
||||
async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") {
|
||||
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
|
||||
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
|
||||
keyType,
|
||||
|
||||
@@ -8,11 +8,11 @@ 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 { test as base, expect, Fixtures } from "../../element-web-test";
|
||||
import { viewRoomSummaryByName } from "../right-panel/utils";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
|
||||
const test = base.extend({
|
||||
const test = base.extend<Fixtures>({
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
startHomeserverOpts: async ({}, use) => {
|
||||
await use("dehydration");
|
||||
@@ -50,8 +50,6 @@ test.describe("Dehydration", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "./utils";
|
||||
import { Bot } from "../../pages/bot";
|
||||
|
||||
test.describe("Device verification", () => {
|
||||
test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
let aliceBotClient: Bot;
|
||||
|
||||
/** The backup version that was set up by the bot client. */
|
||||
|
||||
@@ -53,6 +53,8 @@ test.describe("Cryptography", function () {
|
||||
|
||||
// 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);
|
||||
|
||||
await app.client.network.setupRoute();
|
||||
});
|
||||
|
||||
test("should show the correct shield on e2e events", async ({
|
||||
@@ -133,8 +135,7 @@ test.describe("Cryptography", function () {
|
||||
"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.
|
||||
/* 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
|
||||
@@ -168,9 +169,7 @@ test.describe("Cryptography", function () {
|
||||
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.",
|
||||
"Encrypted by a device not verified by its owner.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ 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";
|
||||
import { expect, Fixtures, test as base } from "../../element-web-test";
|
||||
|
||||
const test = base.extend({
|
||||
const test = base.extend<Fixtures>({
|
||||
// Replace the `user` fixture with one which populates the indexeddb data before starting the app.
|
||||
user: async ({ context, pageWithCredentials: page, credentials }, use) => {
|
||||
await page.route(`/test_indexeddb_cryptostore_dump/*`, async (route, request) => {
|
||||
@@ -25,11 +25,10 @@ const test = base.extend({
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("migration", function () {
|
||||
test.describe("migration", { tag: "@no-webkit" }, 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
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { doTwoWaySasVerification, awaitVerifier } from "./utils";
|
||||
import { Client } from "../../pages/client";
|
||||
@@ -38,6 +39,8 @@ test.describe("User verification", () => {
|
||||
toasts,
|
||||
room: { roomId: dmRoomId },
|
||||
}) => {
|
||||
await waitForDeviceKeys(page);
|
||||
|
||||
// once Alice has joined, Bob starts the verification
|
||||
const bobVerificationRequest = await bob.evaluateHandle(
|
||||
async (client, { dmRoomId, aliceCredentials }) => {
|
||||
@@ -87,6 +90,8 @@ test.describe("User verification", () => {
|
||||
toasts,
|
||||
room: { roomId: dmRoomId },
|
||||
}) => {
|
||||
await waitForDeviceKeys(page);
|
||||
|
||||
// once Alice has joined, Bob starts the verification
|
||||
const bobVerificationRequest = await bob.evaluateHandle(
|
||||
async (client, { dmRoomId, aliceCredentials }) => {
|
||||
@@ -149,3 +154,15 @@ async function createDMRoom(client: Client, userId: string): Promise<string> {
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until we get the other user's device keys.
|
||||
* In newer rust-crypto versions, the verification request will be ignored if we
|
||||
* don't have the sender's device keys.
|
||||
*/
|
||||
async function waitForDeviceKeys(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
|
||||
const avatar = await page.getByRole("button", { name: "Avatar" });
|
||||
await avatar.click();
|
||||
await expect(page.getByText("1 session")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -220,11 +220,7 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Ver
|
||||
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());
|
||||
await expect(emojiBlock).toHaveText(emoji[0] + emoji[1]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
playwright/e2e/csAPI.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 { APIRequestContext } from "playwright-core";
|
||||
import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { HomeserverInstance } from "../plugins/homeserver";
|
||||
|
||||
/**
|
||||
* A small subset of the Client-Server API used to manipulate the state of the
|
||||
* account on the homeserver independently of the client under test.
|
||||
*/
|
||||
export class TestClientServerAPI {
|
||||
public constructor(
|
||||
private request: APIRequestContext,
|
||||
private homeserver: HomeserverInstance,
|
||||
private accessToken: string,
|
||||
) {}
|
||||
|
||||
public async getCurrentBackupInfo(): Promise<KeyBackupInfo | null> {
|
||||
const res = await this.request.get(`${this.homeserver.config.baseUrl}/_matrix/client/v3/room_keys/version`, {
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the API directly to create a new backup version.
|
||||
* @param algorithm The backup algorithm to use.
|
||||
* @param authData The backup auth data
|
||||
* @returns The version number of the new backup
|
||||
*/
|
||||
public async deleteBackupVersion(version: string): Promise<void> {
|
||||
const res = await this.request.delete(
|
||||
`${this.homeserver.config.baseUrl}/_matrix/client/v3/room_keys/version/${version}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to delete backup version: ${res.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
|
||||
|
||||
const ROOM_NAME = "Integration Manager Test";
|
||||
|
||||
@@ -92,7 +93,7 @@ test.describe("Integration Manager: Get OpenID Token", () => {
|
||||
},
|
||||
},
|
||||
id: "integration-manager",
|
||||
},
|
||||
} as unknown as UserWidget,
|
||||
});
|
||||
|
||||
// Succeed when checking the token is valid
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
|
||||
|
||||
const ROOM_NAME = "Integration Manager Test";
|
||||
const USER_DISPLAY_NAME = "Alice";
|
||||
@@ -136,7 +137,7 @@ test.describe("Integration Manager: Kick", () => {
|
||||
},
|
||||
},
|
||||
id: "integration-manager",
|
||||
},
|
||||
} as unknown as UserWidget,
|
||||
});
|
||||
|
||||
// Succeed when checking the token is valid
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
|
||||
|
||||
const ROOM_NAME = "Integration Manager Test";
|
||||
|
||||
@@ -107,7 +108,7 @@ test.describe("Integration Manager: Read Events", () => {
|
||||
},
|
||||
},
|
||||
id: "integration-manager",
|
||||
},
|
||||
} as unknown as UserWidget,
|
||||
});
|
||||
|
||||
// Succeed when checking the token is valid
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
|
||||
|
||||
const ROOM_NAME = "Integration Manager Test";
|
||||
|
||||
@@ -113,7 +114,7 @@ test.describe("Integration Manager: Send Event", () => {
|
||||
},
|
||||
},
|
||||
id: "integration-manager",
|
||||
},
|
||||
} as unknown as UserWidget,
|
||||
});
|
||||
|
||||
// Succeed when checking the token is valid
|
||||
|
||||
@@ -25,12 +25,13 @@ test.describe("Lazy Loading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page, homeserver, user, bot }) => {
|
||||
test.beforeEach(async ({ page, homeserver, user, bot, app }) => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const displayName = `Charly #${i}`;
|
||||
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
|
||||
charlies.push(bot);
|
||||
}
|
||||
await app.client.network.setupRoute();
|
||||
});
|
||||
|
||||
const name = "Lazy Loading Test";
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Locator, Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
|
||||
test.describe("Location sharing", () => {
|
||||
// Firefox headless lacks WebGL support https://bugzilla.mozilla.org/show_bug.cgi?id=1375585
|
||||
test.describe("Location sharing", { tag: "@no-firefox" }, () => {
|
||||
const selectLocationShareTypeOption = (page: Page, shareType: string): Locator => {
|
||||
return page.getByTestId(`share-location-option-${shareType}`);
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect, registerAccountMas } from ".";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
|
||||
test.describe("OIDC Aware", () => {
|
||||
test.skip(isDendrite, "does not yet support MAS");
|
||||
test.slow(); // trace recording takes a while here
|
||||
|
||||
test("can register an account and manage it", async ({ context, page, homeserver, mailhog, app }) => {
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
// Eventually, we should end up at the home screen.
|
||||
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
|
||||
await expect(page.getByRole("heading", { name: "Welcome alice", exact: true })).toBeVisible();
|
||||
|
||||
// Open settings and navigate to account management
|
||||
await app.settings.openUserSettings("Account");
|
||||
const newPagePromise = context.waitForEvent("page");
|
||||
await page.getByRole("button", { name: "Manage account" }).click();
|
||||
|
||||
// Assert new tab opened
|
||||
const newPage = await newPagePromise;
|
||||
await expect(newPage.getByText("Primary email")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -10,14 +10,10 @@ import { test, expect, registerAccountMas } from ".";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { ElementAppPage } from "../../pages/ElementAppPage.ts";
|
||||
|
||||
test.describe("OIDC Native", () => {
|
||||
test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
test.skip(isDendrite, "does not yet support MAS");
|
||||
test.slow(); // trace recording takes a while here
|
||||
|
||||
test.use({
|
||||
labsFlags: ["feature_oidc_native_flow"],
|
||||
});
|
||||
|
||||
test("can register the oauth2 client and an account", async ({ context, page, homeserver, mailhog, mas }) => {
|
||||
const tokenUri = `http://localhost:${mas.port}/oauth2/token`;
|
||||
const tokenApiPromise = page.waitForRequest(
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Client } from "../../pages/client";
|
||||
import { ElementAppPage } from "../../pages/ElementAppPage";
|
||||
import { Bot } from "../../pages/bot";
|
||||
|
||||
type RoomRef = { name: string; roomId: string };
|
||||
|
||||
/**
|
||||
* Set up for pinned message tests.
|
||||
*/
|
||||
@@ -47,7 +49,7 @@ export class Helpers {
|
||||
* @param room - the name of the room to send messages into
|
||||
* @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf`
|
||||
*/
|
||||
async receiveMessages(room: string | { name: string }, messages: string[]) {
|
||||
async receiveMessages(room: RoomRef, messages: string[]) {
|
||||
await this.sendMessageAsClient(this.bot, room, messages);
|
||||
}
|
||||
|
||||
@@ -55,9 +57,8 @@ export class Helpers {
|
||||
* Use the supplied client to send messages or perform actions as specified by
|
||||
* the supplied {@link Message} items.
|
||||
*/
|
||||
private async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: string[]) {
|
||||
const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name);
|
||||
const roomId = await room.evaluate((room) => room.roomId);
|
||||
private async sendMessageAsClient(cli: Client, room: RoomRef, messages: string[]) {
|
||||
const roomId = room.roomId;
|
||||
|
||||
for (const message of messages) {
|
||||
await cli.sendMessage(roomId, { body: message, msgtype: "m.text" });
|
||||
@@ -73,22 +74,11 @@ export class Helpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a room by its name
|
||||
* @param roomName
|
||||
* @private
|
||||
*/
|
||||
private async findRoomByName(roomName: string) {
|
||||
return this.app.client.evaluateHandle((cli, roomName) => {
|
||||
return cli.getRooms().find((r) => r.name === roomName);
|
||||
}, roomName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the room with the supplied name.
|
||||
*/
|
||||
async goTo(room: string | { name: string }) {
|
||||
await this.app.viewRoomByName(typeof room === "string" ? room : room.name);
|
||||
async goTo(room: RoomRef) {
|
||||
await this.app.viewRoomByName(room.name);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,7 +120,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
await util.assertUnread(room2, 40);
|
||||
|
||||
// When I jump to a message in the middle and page up
|
||||
await msg.jumpTo(room2.name, "x\ny\nz\nMsg0020");
|
||||
await msg.jumpTo(room2, "x\ny\nz\nMsg0020");
|
||||
await util.pageUp();
|
||||
|
||||
// Then the room is still unread
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Bot } from "../../pages/bot";
|
||||
import { Client } from "../../pages/client";
|
||||
import { ElementAppPage } from "../../pages/ElementAppPage";
|
||||
|
||||
type RoomRef = { name: string; roomId: string };
|
||||
|
||||
/**
|
||||
* Set up for a read receipt test:
|
||||
* - Create a user with the supplied name
|
||||
@@ -22,9 +24,9 @@ import { ElementAppPage } from "../../pages/ElementAppPage";
|
||||
*/
|
||||
export const test = base.extend<{
|
||||
roomAlphaName?: string;
|
||||
roomAlpha: { name: string; roomId: string };
|
||||
roomAlpha: RoomRef;
|
||||
roomBetaName?: string;
|
||||
roomBeta: { name: string; roomId: string };
|
||||
roomBeta: RoomRef;
|
||||
msg: MessageBuilder;
|
||||
util: Helpers;
|
||||
}>({
|
||||
@@ -248,12 +250,13 @@ export class MessageBuilder {
|
||||
/**
|
||||
* Find and display a message.
|
||||
*
|
||||
* @param roomName the name of the room to look inside
|
||||
* @param roomRef the ref of the room to look inside
|
||||
* @param message the content of the message to fine
|
||||
* @param includeThreads look for messages inside threads, not just the main timeline
|
||||
*/
|
||||
async jumpTo(roomName: string, message: string, includeThreads = false) {
|
||||
const room = await this.helpers.findRoomByName(roomName);
|
||||
async jumpTo(roomRef: RoomRef, message: string, includeThreads = false) {
|
||||
const room = await this.helpers.findRoomById(roomRef.roomId);
|
||||
expect(room).toBeTruthy();
|
||||
const foundMessage = await this.getMessage(room, message, includeThreads);
|
||||
const roomId = await room.evaluate((room) => room.roomId);
|
||||
const foundMessageId = await foundMessage.evaluate((ev) => ev.getId());
|
||||
@@ -333,9 +336,10 @@ class Helpers {
|
||||
* Use the supplied client to send messages or perform actions as specified by
|
||||
* the supplied {@link Message} items.
|
||||
*/
|
||||
async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: Message[]) {
|
||||
const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name);
|
||||
const roomId = await room.evaluate((room) => room.roomId);
|
||||
async sendMessageAsClient(cli: Client, roomRef: RoomRef, messages: Message[]) {
|
||||
const roomId = roomRef.roomId;
|
||||
const room = await this.findRoomById(roomId);
|
||||
expect(room).toBeTruthy();
|
||||
|
||||
for (const message of messages) {
|
||||
if (typeof message === "string") {
|
||||
@@ -359,7 +363,7 @@ class Helpers {
|
||||
/**
|
||||
* Open the room with the supplied name.
|
||||
*/
|
||||
async goTo(room: string | { name: string }) {
|
||||
async goTo(room: RoomRef) {
|
||||
await this.app.viewRoomByName(typeof room === "string" ? room : room.name);
|
||||
}
|
||||
|
||||
@@ -423,17 +427,16 @@ class Helpers {
|
||||
});
|
||||
}
|
||||
|
||||
getRoomListTile(room: string | { name: string }) {
|
||||
const roomName = typeof room === "string" ? room : room.name;
|
||||
return this.page.getByRole("treeitem", { name: new RegExp("^" + roomName) });
|
||||
getRoomListTile(label: string) {
|
||||
return this.page.getByRole("treeitem", { name: new RegExp("^" + label) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Mark as Read" context menu item on the room with the supplied name
|
||||
* in the room list.
|
||||
*/
|
||||
async markAsRead(room: string | { name: string }) {
|
||||
await this.getRoomListTile(room).click({ button: "right" });
|
||||
async markAsRead(room: RoomRef) {
|
||||
await this.getRoomListTile(room.name).click({ button: "right" });
|
||||
await this.page.getByText("Mark as read").click();
|
||||
}
|
||||
|
||||
@@ -441,8 +444,8 @@ class Helpers {
|
||||
* Assert that the room with the supplied name is "read" in the room list - i.g.
|
||||
* has not dot or count of unread messages.
|
||||
*/
|
||||
async assertRead(room: string | { name: string }) {
|
||||
const tile = this.getRoomListTile(room);
|
||||
async assertRead(room: RoomRef) {
|
||||
const tile = this.getRoomListTile(room.name);
|
||||
await expect(tile.locator(".mx_NotificationBadge_dot")).not.toBeVisible();
|
||||
await expect(tile.locator(".mx_NotificationBadge_count")).not.toBeVisible();
|
||||
}
|
||||
@@ -452,7 +455,7 @@ class Helpers {
|
||||
* (In practice, this just waits a short while to allow any unread marker to
|
||||
* appear, and then asserts that the room is read.)
|
||||
*/
|
||||
async assertStillRead(room: string | { name: string }) {
|
||||
async assertStillRead(room: RoomRef) {
|
||||
await this.page.waitForTimeout(200);
|
||||
await this.assertRead(room);
|
||||
}
|
||||
@@ -462,8 +465,8 @@ class Helpers {
|
||||
* @param room - the name of the room to check
|
||||
* @param count - the numeric count to assert, or if "." specified then a bold/dot (no count) state is asserted
|
||||
*/
|
||||
async assertUnread(room: string | { name: string }, count: number | ".") {
|
||||
const tile = this.getRoomListTile(room);
|
||||
async assertUnread(room: RoomRef, count: number | ".") {
|
||||
const tile = this.getRoomListTile(room.name);
|
||||
if (count === ".") {
|
||||
await expect(tile.locator(".mx_NotificationBadge_dot")).toBeVisible();
|
||||
} else {
|
||||
@@ -478,8 +481,8 @@ class Helpers {
|
||||
* @param room - the name of the room to check
|
||||
* @param lessThan - the number of unread messages that is too many
|
||||
*/
|
||||
async assertUnreadLessThan(room: string | { name: string }, lessThan: number) {
|
||||
const tile = this.getRoomListTile(room);
|
||||
async assertUnreadLessThan(room: RoomRef, lessThan: number) {
|
||||
const tile = this.getRoomListTile(room.name);
|
||||
// https://playwright.dev/docs/test-assertions#expectpoll
|
||||
// .toBeLessThan doesn't have a retry mechanism, so we use .poll
|
||||
await expect
|
||||
@@ -496,8 +499,8 @@ class Helpers {
|
||||
* @param room - the name of the room to check
|
||||
* @param greaterThan - the number of unread messages that is too few
|
||||
*/
|
||||
async assertUnreadGreaterThan(room: string | { name: string }, greaterThan: number) {
|
||||
const tile = this.getRoomListTile(room);
|
||||
async assertUnreadGreaterThan(room: RoomRef, greaterThan: number) {
|
||||
const tile = this.getRoomListTile(room.name);
|
||||
// https://playwright.dev/docs/test-assertions#expectpoll
|
||||
// .toBeGreaterThan doesn't have a retry mechanism, so we use .poll
|
||||
await expect
|
||||
@@ -531,10 +534,10 @@ class Helpers {
|
||||
});
|
||||
}
|
||||
|
||||
async findRoomByName(roomName: string): Promise<JSHandle<Room>> {
|
||||
return this.app.client.evaluateHandle((cli, roomName) => {
|
||||
return cli.getRooms().find((r) => r.name === roomName);
|
||||
}, roomName);
|
||||
async findRoomById(roomId: string): Promise<JSHandle<Room>> {
|
||||
return this.app.client.evaluateHandle((cli, roomId) => {
|
||||
return cli.getRooms().find((r) => r.roomId === roomId);
|
||||
}, roomId);
|
||||
}
|
||||
|
||||
private async getThreadListTile(rootMessage: string) {
|
||||
@@ -578,7 +581,7 @@ class Helpers {
|
||||
* @param room - the name of the room to send messages into
|
||||
* @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf`
|
||||
*/
|
||||
async receiveMessages(room: string | { name: string }, messages: Message[]) {
|
||||
async receiveMessages(room: RoomRef, messages: Message[]) {
|
||||
await this.sendMessageAsClient(this.bot, room, messages);
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
await util.goTo(room1);
|
||||
|
||||
// When I read an older message in the thread
|
||||
await msg.jumpTo(room2.name, "InThread0000", true);
|
||||
await msg.jumpTo(room2, "InThread0000", true);
|
||||
|
||||
// Then the thread is still marked as unread
|
||||
await util.backToThreadsList();
|
||||
|
||||
@@ -59,7 +59,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
await util.assertUnread(room2, 30);
|
||||
|
||||
// When I jump to one of the older messages
|
||||
await msg.jumpTo(room2.name, "Msg0001");
|
||||
await msg.jumpTo(room2, "Msg0001");
|
||||
|
||||
// Then the room is still unread, but some messages were read
|
||||
await util.assertUnreadLessThan(room2, 30);
|
||||
|
||||
@@ -49,7 +49,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
await util.assertUnread(room2, 61); // Sanity
|
||||
|
||||
// When I jump to an old message and read the thread
|
||||
await msg.jumpTo(room2.name, "beforeThread0000");
|
||||
await msg.jumpTo(room2, "beforeThread0000");
|
||||
// When the thread is opened, the timeline is scrolled until the thread root reached the center
|
||||
await util.openThread("ThreadRoot");
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
await sendThreadedReadReceipt(app, thread1a, main1);
|
||||
|
||||
// Then the room has only one unread - the one in the thread
|
||||
await util.goTo(otherRoomName);
|
||||
await util.goTo({ name: otherRoomName, roomId: otherRoomId });
|
||||
await util.assertUnreadThread("Message 1");
|
||||
});
|
||||
|
||||
@@ -214,7 +214,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
|
||||
// Then the room has no unreads
|
||||
await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
|
||||
await util.goTo(otherRoomName);
|
||||
await util.goTo({ name: otherRoomName, roomId: otherRoomId });
|
||||
await util.assertReadThread("Message 1");
|
||||
});
|
||||
|
||||
@@ -239,7 +239,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
// receipt is for a later event. The room should therefore be
|
||||
// read, and the thread unread.
|
||||
await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
|
||||
await util.goTo(otherRoomName);
|
||||
await util.goTo({ name: otherRoomName, roomId: otherRoomId });
|
||||
await util.assertUnreadThread("Message 1");
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { test, expect } from "../../element-web-test";
|
||||
|
||||
test.describe("Registration", () => {
|
||||
test.use({ startHomeserverOpts: "consent" });
|
||||
test.use({
|
||||
startHomeserverOpts: "consent",
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/#/register");
|
||||
|
||||
@@ -39,7 +39,7 @@ test.describe("FilePanel", () => {
|
||||
await expect(page.locator(".mx_FilePanel")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("render", () => {
|
||||
test.describe("render", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
test("should render empty state", { tag: "@screenshot" }, async ({ page }) => {
|
||||
// Wait until the information about the empty state is rendered
|
||||
await expect(page.locator(".mx_EmptyState")).toBeVisible();
|
||||
|
||||
@@ -15,37 +15,43 @@ test.describe("Room Directory", () => {
|
||||
botCreateOpts: { displayName: "Paul" },
|
||||
});
|
||||
|
||||
test("should allow admin to add alias & publish room to directory", async ({ page, app, user, bot }) => {
|
||||
const roomId = await app.client.createRoom({
|
||||
name: "Gaming",
|
||||
preset: "public_chat" as Preset,
|
||||
});
|
||||
test(
|
||||
"should allow admin to add alias & publish room to directory",
|
||||
{ tag: "@no-webkit" },
|
||||
async ({ page, app, user, bot }) => {
|
||||
const roomId = await app.client.createRoom({
|
||||
name: "Gaming",
|
||||
preset: "public_chat" as Preset,
|
||||
});
|
||||
|
||||
await app.viewRoomByName("Gaming");
|
||||
await app.settings.openRoomSettings();
|
||||
await app.viewRoomByName("Gaming");
|
||||
await app.settings.openRoomSettings();
|
||||
|
||||
// First add a local address `gaming`
|
||||
const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" });
|
||||
await localAddresses.getByRole("textbox").fill("gaming");
|
||||
await localAddresses.getByRole("button", { name: "Add" }).click();
|
||||
await expect(localAddresses.getByText("#gaming:localhost")).toHaveClass("mx_EditableItem_item");
|
||||
// First add a local address `gaming`
|
||||
const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" });
|
||||
await localAddresses.getByRole("textbox").fill("gaming");
|
||||
await localAddresses.getByRole("button", { name: "Add" }).click();
|
||||
await expect(localAddresses.getByText("#gaming:localhost")).toHaveClass("mx_EditableItem_item");
|
||||
|
||||
// Publish into the public rooms directory
|
||||
const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" });
|
||||
await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue("#gaming:localhost");
|
||||
const checkbox = publishedAddresses
|
||||
.locator(".mx_SettingsFlag", { hasText: "Publish this room to the public in localhost's room directory?" })
|
||||
.getByRole("switch");
|
||||
await checkbox.check();
|
||||
await expect(checkbox).toBeChecked();
|
||||
// Publish into the public rooms directory
|
||||
const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" });
|
||||
await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue("#gaming:localhost");
|
||||
const checkbox = publishedAddresses
|
||||
.locator(".mx_SettingsFlag", {
|
||||
hasText: "Publish this room to the public in localhost's room directory?",
|
||||
})
|
||||
.getByRole("switch");
|
||||
await checkbox.check();
|
||||
await expect(checkbox).toBeChecked();
|
||||
|
||||
await app.closeDialog();
|
||||
await app.closeDialog();
|
||||
|
||||
const resp = await bot.publicRooms({});
|
||||
expect(resp.total_room_count_estimate).toEqual(1);
|
||||
expect(resp.chunk).toHaveLength(1);
|
||||
expect(resp.chunk[0].room_id).toEqual(roomId);
|
||||
});
|
||||
const resp = await bot.publicRooms({});
|
||||
expect(resp.total_room_count_estimate).toEqual(1);
|
||||
expect(resp.chunk).toHaveLength(1);
|
||||
expect(resp.chunk[0].room_id).toEqual(roomId);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should allow finding published rooms in directory",
|
||||
|
||||
@@ -71,7 +71,9 @@ test.describe("Room Header", () => {
|
||||
|
||||
// Assert the size of buttons on RoomHeader are specified and the buttons are not compressed
|
||||
// Note these assertions do not check the size of mx_LegacyRoomHeader_name button
|
||||
const buttons = header.locator(".mx_Flex").getByRole("button");
|
||||
const buttons = header.getByRole("button").filter({
|
||||
has: page.locator("svg"),
|
||||
});
|
||||
await expect(buttons).toHaveCount(5);
|
||||
|
||||
for (const button of await buttons.all()) {
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { EventType } from "matrix-js-sdk/src/matrix";
|
||||
import type { AccountDataEvents } from "matrix-js-sdk/src/matrix";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { Bot } from "../../pages/bot";
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe("Room Directory", () => {
|
||||
const charlieRoom = await cli.createRoom({ is_direct: true });
|
||||
await cli.invite(bobRoom.room_id, bob);
|
||||
await cli.invite(charlieRoom.room_id, charlie);
|
||||
await cli.setAccountData("m.direct" as EventType, {
|
||||
await cli.setAccountData("m.direct" as keyof AccountDataEvents, {
|
||||
[bob]: [bobRoom.room_id],
|
||||
[charlie]: [charlieRoom.room_id],
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ test.describe("General room settings tab", () => {
|
||||
await expect(settings.getByText("Show more")).toBeVisible();
|
||||
});
|
||||
|
||||
test("long address should not cause dialog to overflow", async ({ page, app }) => {
|
||||
test("long address should not cause dialog to overflow", { tag: "@no-webkit" }, async ({ page, app }) => {
|
||||
const settings = await app.settings.openRoomSettings("General");
|
||||
// 1. Set the room-address to be a really long string
|
||||
const longString = "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksd".repeat(4);
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe("Preferences user settings tab", () => {
|
||||
await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png");
|
||||
});
|
||||
|
||||
test("should be able to change the app language", async ({ uut, user }) => {
|
||||
test("should be able to change the app language", { tag: ["@no-firefox", "@no-webkit"] }, async ({ uut, user }) => {
|
||||
// Check language and region setting dropdown
|
||||
const languageInput = uut.getByRole("button", { name: "Language Dropdown" });
|
||||
await languageInput.scrollIntoViewIfNeeded();
|
||||
|
||||
@@ -8,17 +8,57 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { Page, Request } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { test as base, expect } from "../../element-web-test";
|
||||
import type { ElementAppPage } from "../../pages/ElementAppPage";
|
||||
import type { Bot } from "../../pages/bot";
|
||||
import { ProxyInstance, SlidingSyncProxy } from "../../plugins/sliding-sync-proxy";
|
||||
|
||||
const test = base.extend<{
|
||||
slidingSyncProxy: ProxyInstance;
|
||||
testRoom: { roomId: string; name: string };
|
||||
joinedBot: Bot;
|
||||
}>({
|
||||
slidingSyncProxy: async ({ context, page, homeserver }, use) => {
|
||||
const proxy = new SlidingSyncProxy(homeserver.config.dockerUrl, context);
|
||||
const proxyInstance = await proxy.start();
|
||||
const proxyAddress = `http://localhost:${proxyInstance.port}`;
|
||||
await page.addInitScript((proxyAddress) => {
|
||||
window.localStorage.setItem(
|
||||
"mx_local_settings",
|
||||
JSON.stringify({
|
||||
feature_sliding_sync_proxy_url: proxyAddress,
|
||||
}),
|
||||
);
|
||||
window.localStorage.setItem("mx_labs_feature_feature_sliding_sync", "true");
|
||||
}, proxyAddress);
|
||||
await use(proxyInstance);
|
||||
await proxy.stop();
|
||||
},
|
||||
// Ensure slidingSyncProxy is set up before the user fixture as it relies on an init script
|
||||
credentials: async ({ slidingSyncProxy, credentials }, use) => {
|
||||
await use(credentials);
|
||||
},
|
||||
testRoom: async ({ user, app }, use) => {
|
||||
const name = "Test Room";
|
||||
const roomId = await app.client.createRoom({ name });
|
||||
await use({ roomId, name });
|
||||
},
|
||||
joinedBot: async ({ app, bot, testRoom }, use) => {
|
||||
const roomId = testRoom.roomId;
|
||||
await bot.prepareClient();
|
||||
const bobUserId = await bot.evaluate((client) => client.getUserId());
|
||||
await app.client.evaluate(
|
||||
async (client, { bobUserId, roomId }) => {
|
||||
await client.invite(roomId, bobUserId);
|
||||
},
|
||||
{ bobUserId, roomId },
|
||||
);
|
||||
await bot.joinRoom(roomId);
|
||||
await use(bot);
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Sliding Sync", () => {
|
||||
let roomId: string;
|
||||
|
||||
test.beforeEach(async ({ slidingSyncProxy, page, user, app }) => {
|
||||
roomId = await app.client.createRoom({ name: "Test Room" });
|
||||
});
|
||||
|
||||
const checkOrder = async (wantOrder: string[], page: Page) => {
|
||||
await expect(page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomTile_title")).toHaveText(wantOrder);
|
||||
};
|
||||
@@ -32,22 +72,13 @@ test.describe("Sliding Sync", () => {
|
||||
});
|
||||
};
|
||||
|
||||
const createAndJoinBot = async (app: ElementAppPage, bot: Bot): Promise<Bot> => {
|
||||
await bot.prepareClient();
|
||||
const bobUserId = await bot.evaluate((client) => client.getUserId());
|
||||
await app.client.evaluate(
|
||||
async (client, { bobUserId, roomId }) => {
|
||||
await client.invite(roomId, bobUserId);
|
||||
},
|
||||
{ bobUserId, roomId },
|
||||
);
|
||||
await bot.joinRoom(roomId);
|
||||
return bot;
|
||||
};
|
||||
// Load the user fixture for all tests
|
||||
test.beforeEach(({ user }) => {});
|
||||
|
||||
test.skip("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", async ({
|
||||
test("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", async ({
|
||||
page,
|
||||
app,
|
||||
testRoom,
|
||||
}) => {
|
||||
// create rooms and check room names are correct
|
||||
for (const fruit of ["Apple", "Pineapple", "Orange"]) {
|
||||
@@ -55,7 +86,7 @@ test.describe("Sliding Sync", () => {
|
||||
await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible();
|
||||
}
|
||||
|
||||
// Check count, 3 fruits + 1 room created in beforeEach = 4
|
||||
// Check count, 3 fruits + 1 testRoom = 4
|
||||
await expect(page.locator(".mx_RoomSublist_tiles").getByRole("treeitem")).toHaveCount(4);
|
||||
await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page);
|
||||
|
||||
@@ -71,7 +102,7 @@ test.describe("Sliding Sync", () => {
|
||||
await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page);
|
||||
});
|
||||
|
||||
test.skip("should move rooms around as new events arrive", async ({ page, app }) => {
|
||||
test("should move rooms around as new events arrive", async ({ page, app, testRoom }) => {
|
||||
// create rooms and check room names are correct
|
||||
const roomIds: string[] = [];
|
||||
for (const fruit of ["Apple", "Pineapple", "Orange"]) {
|
||||
@@ -94,7 +125,7 @@ test.describe("Sliding Sync", () => {
|
||||
await checkOrder(["Pineapple", "Orange", "Apple", "Test Room"], page);
|
||||
});
|
||||
|
||||
test.skip("should not move the selected room: it should be sticky", async ({ page, app }) => {
|
||||
test("should not move the selected room: it should be sticky", async ({ page, app, testRoom }) => {
|
||||
// create rooms and check room names are correct
|
||||
const roomIds: string[] = [];
|
||||
for (const fruit of ["Apple", "Pineapple", "Orange"]) {
|
||||
@@ -122,11 +153,9 @@ test.describe("Sliding Sync", () => {
|
||||
await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page);
|
||||
});
|
||||
|
||||
test.skip("should show the right unread notifications", async ({ page, app, user, bot }) => {
|
||||
const bob = await createAndJoinBot(app, bot);
|
||||
|
||||
test.skip("should show the right unread notifications", async ({ page, user, joinedBot: bob, testRoom }) => {
|
||||
// send a message in the test room: unread notification count should increment
|
||||
await bob.sendMessage(roomId, "Hello World");
|
||||
await bob.sendMessage(testRoom.roomId, "Hello World");
|
||||
|
||||
const treeItemLocator1 = page.getByRole("treeitem", { name: "Test Room 1 unread message." });
|
||||
await expect(treeItemLocator1.locator(".mx_NotificationBadge_count")).toHaveText("1");
|
||||
@@ -136,7 +165,7 @@ test.describe("Sliding Sync", () => {
|
||||
);
|
||||
|
||||
// send an @mention: highlight count (red) should be 2.
|
||||
await bob.sendMessage(roomId, `Hello ${user.displayName}`);
|
||||
await bob.sendMessage(testRoom.roomId, `Hello ${user.displayName}`);
|
||||
const treeItemLocator2 = page.getByRole("treeitem", {
|
||||
name: "Test Room 2 unread messages including mentions.",
|
||||
});
|
||||
@@ -150,9 +179,8 @@ test.describe("Sliding Sync", () => {
|
||||
).not.toBeAttached();
|
||||
});
|
||||
|
||||
test.skip("should not show unread indicators", async ({ page, app, bot }) => {
|
||||
test("should not show unread indicators", async ({ page, app, joinedBot: bot, testRoom }) => {
|
||||
// TODO: for now. Later we should.
|
||||
await createAndJoinBot(app, bot);
|
||||
|
||||
// disable notifs in this room (TODO: CS API call?)
|
||||
const locator = page.getByRole("treeitem", { name: "Test Room" });
|
||||
@@ -165,7 +193,7 @@ test.describe("Sliding Sync", () => {
|
||||
|
||||
await checkOrder(["Dummy", "Test Room"], page);
|
||||
|
||||
await bot.sendMessage(roomId, "Do you read me?");
|
||||
await bot.sendMessage(testRoom.roomId, "Do you read me?");
|
||||
|
||||
// wait for this message to arrive, tell by the room list resorting
|
||||
await checkOrder(["Test Room", "Dummy"], page);
|
||||
@@ -178,15 +206,18 @@ test.describe("Sliding Sync", () => {
|
||||
test("should update user settings promptly", async ({ page, app }) => {
|
||||
await app.settings.openUserSettings("Preferences");
|
||||
const locator = page.locator(".mx_SettingsFlag").filter({ hasText: "Show timestamps in 12 hour format" });
|
||||
expect(locator).toBeVisible();
|
||||
expect(locator.locator(".mx_ToggleSwitch_on")).not.toBeAttached();
|
||||
await expect(locator).toBeVisible();
|
||||
await expect(locator.locator(".mx_ToggleSwitch_on")).not.toBeAttached();
|
||||
await locator.locator(".mx_ToggleSwitch_ball").click();
|
||||
expect(locator.locator(".mx_ToggleSwitch_on")).toBeAttached();
|
||||
await expect(locator.locator(".mx_ToggleSwitch_on")).toBeAttached();
|
||||
});
|
||||
|
||||
test.skip("should show and be able to accept/reject/rescind invites", async ({ page, app, bot }) => {
|
||||
await createAndJoinBot(app, bot);
|
||||
|
||||
test("should show and be able to accept/reject/rescind invites", async ({
|
||||
page,
|
||||
app,
|
||||
joinedBot: bot,
|
||||
testRoom,
|
||||
}) => {
|
||||
const clientUserId = await app.client.evaluate((client) => client.getUserId());
|
||||
|
||||
// invite bot into 3 rooms:
|
||||
@@ -262,10 +293,10 @@ test.describe("Sliding Sync", () => {
|
||||
|
||||
// Regression test for a bug in SS mode, but would be useful to have in non-SS mode too.
|
||||
// This ensures we are setting RoomViewStore state correctly.
|
||||
test.skip("should clear the reply to field when swapping rooms", async ({ page, app }) => {
|
||||
test("should clear the reply to field when swapping rooms", async ({ page, app, testRoom }) => {
|
||||
await app.client.createRoom({ name: "Other Room" });
|
||||
await expect(page.getByRole("treeitem", { name: "Other Room" })).toBeVisible();
|
||||
await app.client.sendMessage(roomId, "Hello world");
|
||||
await app.client.sendMessage(testRoom.roomId, "Hello world");
|
||||
|
||||
// select the room
|
||||
await page.getByRole("treeitem", { name: "Test Room" }).click();
|
||||
@@ -294,11 +325,11 @@ test.describe("Sliding Sync", () => {
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/vector-im/element-web/issues/21462
|
||||
test.skip("should not cancel replies when permalinks are clicked", async ({ page, app }) => {
|
||||
test("should not cancel replies when permalinks are clicked", async ({ page, app, testRoom }) => {
|
||||
// we require a first message as you cannot click the permalink text with the avatar in the way
|
||||
await app.client.sendMessage(roomId, "First message");
|
||||
await app.client.sendMessage(roomId, "Permalink me");
|
||||
await app.client.sendMessage(roomId, "Reply to me");
|
||||
await app.client.sendMessage(testRoom.roomId, "First message");
|
||||
await app.client.sendMessage(testRoom.roomId, "Permalink me");
|
||||
await app.client.sendMessage(testRoom.roomId, "Reply to me");
|
||||
|
||||
// select the room
|
||||
await page.getByRole("treeitem", { name: "Test Room" }).click();
|
||||
@@ -322,7 +353,7 @@ test.describe("Sliding Sync", () => {
|
||||
await expect(page.locator(".mx_ReplyPreview")).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip("should send unsubscribe_rooms for every room switch", async ({ page, app }) => {
|
||||
test("should send unsubscribe_rooms for every room switch", async ({ page, app }) => {
|
||||
// create rooms and check room names are correct
|
||||
const roomIds: string[] = [];
|
||||
for (const fruit of ["Apple", "Pineapple", "Orange"]) {
|
||||
|
||||
@@ -55,38 +55,44 @@ test.describe("Spaces", () => {
|
||||
botCreateOpts: { displayName: "BotBob" },
|
||||
});
|
||||
|
||||
test("should allow user to create public space", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const contextMenu = await openSpaceCreateMenu(page);
|
||||
await expect(contextMenu).toMatchScreenshot("space-create-menu.png");
|
||||
test(
|
||||
"should allow user to create public space",
|
||||
{ tag: ["@screenshot", "@no-webkit"] },
|
||||
async ({ page, app, user }) => {
|
||||
const contextMenu = await openSpaceCreateMenu(page);
|
||||
await expect(contextMenu).toMatchScreenshot("space-create-menu.png");
|
||||
|
||||
await contextMenu.getByRole("button", { name: /Public/ }).click();
|
||||
await contextMenu.getByRole("button", { name: /Public/ }).click();
|
||||
|
||||
await contextMenu
|
||||
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
|
||||
.setInputFiles("playwright/sample-files/riot.png");
|
||||
await contextMenu.getByRole("textbox", { name: "Name" }).fill("Let's have a Riot");
|
||||
await expect(contextMenu.getByRole("textbox", { name: "Address" })).toHaveValue("lets-have-a-riot");
|
||||
await contextMenu.getByRole("textbox", { name: "Description" }).fill("This is a space to reminisce Riot.im!");
|
||||
await contextMenu.getByRole("button", { name: "Create" }).click();
|
||||
await contextMenu
|
||||
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
|
||||
.setInputFiles("playwright/sample-files/riot.png");
|
||||
await contextMenu.getByRole("textbox", { name: "Name" }).fill("Let's have a Riot");
|
||||
await expect(contextMenu.getByRole("textbox", { name: "Address" })).toHaveValue("lets-have-a-riot");
|
||||
await contextMenu
|
||||
.getByRole("textbox", { name: "Description" })
|
||||
.fill("This is a space to reminisce Riot.im!");
|
||||
await contextMenu.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
// Create the default General & Random rooms, as well as a custom "Jokes" room
|
||||
await expect(page.getByPlaceholder("General")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Random")).toBeVisible();
|
||||
await page.getByPlaceholder("Support").fill("Jokes");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
// Create the default General & Random rooms, as well as a custom "Jokes" room
|
||||
await expect(page.getByPlaceholder("General")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Random")).toBeVisible();
|
||||
await page.getByPlaceholder("Support").fill("Jokes");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Copy matrix.to link
|
||||
await page.getByRole("button", { name: "Share invite link" }).click();
|
||||
expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#lets-have-a-riot:localhost");
|
||||
// Copy matrix.to link
|
||||
await page.getByRole("button", { name: "Share invite link" }).click();
|
||||
expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#lets-have-a-riot:localhost");
|
||||
|
||||
// Go to space home
|
||||
await page.getByRole("button", { name: "Go to my first room" }).click();
|
||||
// Go to space home
|
||||
await page.getByRole("button", { name: "Go to my first room" }).click();
|
||||
|
||||
// Assert rooms exist in the room list
|
||||
await expect(page.getByRole("treeitem", { name: "General" })).toBeVisible();
|
||||
await expect(page.getByRole("treeitem", { name: "Random" })).toBeVisible();
|
||||
await expect(page.getByRole("treeitem", { name: "Jokes" })).toBeVisible();
|
||||
});
|
||||
// Assert rooms exist in the room list
|
||||
await expect(page.getByRole("treeitem", { name: "General" })).toBeVisible();
|
||||
await expect(page.getByRole("treeitem", { name: "Random" })).toBeVisible();
|
||||
await expect(page.getByRole("treeitem", { name: "Jokes" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test("should allow user to create private space", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const menu = await openSpaceCreateMenu(page);
|
||||
@@ -157,7 +163,7 @@ test.describe("Spaces", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should allow user to invite another to a space", async ({ page, app, user, bot }) => {
|
||||
test("should allow user to invite another to a space", { tag: "@no-webkit" }, async ({ page, app, user, bot }) => {
|
||||
await app.client.createSpace({
|
||||
visibility: "public" as any,
|
||||
room_alias_name: "space",
|
||||
|
||||
@@ -14,6 +14,8 @@ import { Bot } from "../../../pages/bot";
|
||||
import { Client } from "../../../pages/client";
|
||||
import { ElementAppPage } from "../../../pages/ElementAppPage";
|
||||
|
||||
type RoomRef = { name: string; roomId: string };
|
||||
|
||||
/**
|
||||
* Set up for a read receipt test:
|
||||
* - Create a user with the supplied name
|
||||
@@ -181,9 +183,10 @@ export class Helpers {
|
||||
* Use the supplied client to send messages or perform actions as specified by
|
||||
* the supplied {@link Message} items.
|
||||
*/
|
||||
async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: Message[]) {
|
||||
const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name);
|
||||
const roomId = await room.evaluate((room) => room.roomId);
|
||||
async sendMessageAsClient(cli: Client, roomRef: RoomRef, messages: Message[]) {
|
||||
const roomId = roomRef.roomId;
|
||||
const room = await this.findRoomById(roomId);
|
||||
expect(room).toBeTruthy();
|
||||
|
||||
for (const message of messages) {
|
||||
if (typeof message === "string") {
|
||||
@@ -205,7 +208,7 @@ export class Helpers {
|
||||
/**
|
||||
* Open the room with the supplied name.
|
||||
*/
|
||||
async goTo(room: string | { name: string }) {
|
||||
async goTo(room: RoomRef) {
|
||||
await this.app.viewRoomByName(typeof room === "string" ? room : room.name);
|
||||
}
|
||||
|
||||
@@ -220,10 +223,10 @@ export class Helpers {
|
||||
await expect(this.page.locator(".mx_ThreadView_timelinePanelWrapper")).toBeVisible();
|
||||
}
|
||||
|
||||
async findRoomByName(roomName: string): Promise<JSHandle<Room>> {
|
||||
return this.app.client.evaluateHandle((cli, roomName) => {
|
||||
return cli.getRooms().find((r) => r.name === roomName);
|
||||
}, roomName);
|
||||
async findRoomById(roomId: string): Promise<JSHandle<Room | undefined>> {
|
||||
return this.app.client.evaluateHandle((cli, roomId) => {
|
||||
return cli.getRooms().find((r) => r.roomId === roomId);
|
||||
}, roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,7 +234,7 @@ export class Helpers {
|
||||
* @param room - the name of the room to send messages into
|
||||
* @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf`
|
||||
*/
|
||||
async receiveMessages(room: string | { name: string }, messages: Message[]) {
|
||||
async receiveMessages(room: RoomRef, messages: Message[]) {
|
||||
await this.sendMessageAsClient(this.bot, room, messages);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { expect, test } from ".";
|
||||
import { CommandOrControl } from "../../utils";
|
||||
|
||||
test.describe("Threads Activity Centre", () => {
|
||||
test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
|
||||
test.use({
|
||||
displayName: "Alice",
|
||||
botCreateOpts: { displayName: "Other User" },
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { AccountDataEvents } from "matrix-js-sdk/src/matrix";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { Filter } from "../../pages/Spotlight";
|
||||
import { Bot } from "../../pages/bot";
|
||||
@@ -255,7 +256,9 @@ test.describe("Spotlight", () => {
|
||||
|
||||
// Invite BotBob into existing DM with ByteBot
|
||||
const dmRooms = await app.client.evaluate((client, userId) => {
|
||||
const map = client.getAccountData("m.direct")?.getContent<Record<string, string[]>>();
|
||||
const map = client
|
||||
.getAccountData("m.direct" as keyof AccountDataEvents)
|
||||
?.getContent<Record<string, string[]>>();
|
||||
return map[userId] ?? [];
|
||||
}, bot2UserId);
|
||||
expect(dmRooms).toHaveLength(1);
|
||||
|
||||
@@ -324,7 +324,7 @@ test.describe("Threads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("can send voice messages", async ({ page, app, user }) => {
|
||||
test("can send voice messages", { tag: ["@no-firefox", "@no-webkit"] }, async ({ page, app, user }) => {
|
||||
// Increase right-panel size, so that voice messages fit
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.setItem("mx_rhs_size", "600");
|
||||
@@ -353,7 +353,7 @@ test.describe("Threads", () => {
|
||||
|
||||
test(
|
||||
"should send location and reply to the location on ThreadView",
|
||||
{ tag: "@screenshot" },
|
||||
{ tag: ["@screenshot", "@no-firefox"] },
|
||||
async ({ page, app, bot }) => {
|
||||
const roomId = await app.client.createRoom({});
|
||||
await app.client.inviteUser(roomId, bot.credentials.userId);
|
||||
|
||||
@@ -90,7 +90,7 @@ test.describe("Timeline", () => {
|
||||
let oldAvatarUrl: string;
|
||||
let newAvatarUrl: string;
|
||||
|
||||
test.describe("useOnlyCurrentProfiles", () => {
|
||||
test.describe("useOnlyCurrentProfiles", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
test.beforeEach(async ({ app, user }) => {
|
||||
({ content_uri: oldAvatarUrl } = await app.client.uploadContent(OLD_AVATAR, { type: "image/png" }));
|
||||
await app.client.setAvatarUrl(oldAvatarUrl);
|
||||
@@ -876,7 +876,7 @@ test.describe("Timeline", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("message sending", () => {
|
||||
test.describe("message sending", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
const MESSAGE = "Hello world";
|
||||
const reply = "Reply";
|
||||
const viewRoomSendMessageAndSetupReply = async (page: Page, app: ElementAppPage, roomId: string) => {
|
||||
@@ -914,7 +914,6 @@ test.describe("Timeline", () => {
|
||||
});
|
||||
|
||||
test("can reply with a voice message", async ({ page, app, room, context }) => {
|
||||
await context.grantPermissions(["microphone"]);
|
||||
await viewRoomSendMessageAndSetupReply(page, app, room.roomId);
|
||||
|
||||
const composerOptions = await app.openMessageComposerOptions();
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { ElementAppPage } from "../../pages/ElementAppPage";
|
||||
import { Credentials } from "../../plugins/homeserver";
|
||||
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
|
||||
|
||||
const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker";
|
||||
const STICKER_PICKER_WIDGET_NAME = "Fake Stickers";
|
||||
@@ -123,11 +124,11 @@ async function setWidgetAccountData(
|
||||
state_key: STICKER_PICKER_WIDGET_ID,
|
||||
type: "m.widget",
|
||||
id: STICKER_PICKER_WIDGET_ID,
|
||||
},
|
||||
} as unknown as UserWidget,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Stickers", () => {
|
||||
test.describe("Stickers", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
test.use({
|
||||
displayName: "Sally",
|
||||
room: async ({ app }, use) => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { OAuthServer } from "./plugins/oauth_server";
|
||||
import { Crypto } from "./pages/crypto";
|
||||
import { Toasts } from "./pages/toasts";
|
||||
import { Bot, CreateBotOpts } from "./pages/bot";
|
||||
import { ProxyInstance, SlidingSyncProxy } from "./plugins/sliding-sync-proxy";
|
||||
import { Webserver } from "./plugins/webserver";
|
||||
|
||||
// Enable experimental service worker support
|
||||
@@ -60,7 +59,7 @@ interface CredentialsWithDisplayName extends Credentials {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export const test = base.extend<{
|
||||
export interface Fixtures {
|
||||
axe: AxeBuilder;
|
||||
checkA11y: () => Promise<void>;
|
||||
|
||||
@@ -121,10 +120,19 @@ export const test = base.extend<{
|
||||
uut?: Locator; // Unit Under Test, useful place to refer a prepared locator
|
||||
botCreateOpts: CreateBotOpts;
|
||||
bot: Bot;
|
||||
slidingSyncProxy: ProxyInstance;
|
||||
labsFlags: string[];
|
||||
webserver: Webserver;
|
||||
}>({
|
||||
}
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
context: async ({ context }, use, testInfo) => {
|
||||
// We skip tests instead of using grep-invert to still surface the counts in the html report
|
||||
test.skip(
|
||||
testInfo.tags.includes(`@no-${testInfo.project.name.toLowerCase()}`),
|
||||
`Test does not work on ${testInfo.project.name}`,
|
||||
);
|
||||
await use(context);
|
||||
},
|
||||
config: CONFIG_JSON,
|
||||
page: async ({ context, page, config, labsFlags }, use) => {
|
||||
await context.route(`http://localhost:8080/config.json*`, async (route) => {
|
||||
@@ -241,6 +249,7 @@ export const test = base.extend<{
|
||||
app: async ({ page }, use) => {
|
||||
const app = new ElementAppPage(page);
|
||||
await use(app);
|
||||
await app.cleanup();
|
||||
},
|
||||
crypto: async ({ page, homeserver, request }, use) => {
|
||||
await use(new Crypto(page, homeserver, request));
|
||||
@@ -264,25 +273,6 @@ export const test = base.extend<{
|
||||
await mailhog.stop();
|
||||
},
|
||||
|
||||
slidingSyncProxy: async ({ page, user, homeserver }, use) => {
|
||||
const proxy = new SlidingSyncProxy(homeserver.config.dockerUrl);
|
||||
const proxyInstance = await proxy.start();
|
||||
const proxyAddress = `http://localhost:${proxyInstance.port}`;
|
||||
await page.addInitScript((proxyAddress) => {
|
||||
window.localStorage.setItem(
|
||||
"mx_local_settings",
|
||||
JSON.stringify({
|
||||
feature_sliding_sync_proxy_url: proxyAddress,
|
||||
}),
|
||||
);
|
||||
window.localStorage.setItem("mx_labs_feature_feature_sliding_sync", "true");
|
||||
}, proxyAddress);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
|
||||
await use(proxyInstance);
|
||||
await proxy.stop();
|
||||
},
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
webserver: async ({}, use) => {
|
||||
const webserver = new Webserver();
|
||||
|
||||
@@ -37,6 +37,10 @@ export class ElementAppPage {
|
||||
return this._timeline;
|
||||
}
|
||||
|
||||
public async cleanup() {
|
||||
await this._client?.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the top left user menu, returning a Locator to the resulting context menu.
|
||||
*/
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
Upload,
|
||||
StateEvents,
|
||||
TimelineEvents,
|
||||
AccountDataEvents,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import type { RoomMessageEventContent } from "matrix-js-sdk/src/types";
|
||||
import { Credentials } from "../plugins/homeserver";
|
||||
@@ -51,6 +52,10 @@ export class Client {
|
||||
this.network = new Network(page, this);
|
||||
}
|
||||
|
||||
public async cleanup() {
|
||||
await this.network.destroyRoute();
|
||||
}
|
||||
|
||||
public evaluate<R, Arg, O extends MatrixClient = MatrixClient>(
|
||||
pageFunction: PageFunctionOn<O, Arg, R>,
|
||||
arg: Arg,
|
||||
@@ -174,18 +179,18 @@ export class Client {
|
||||
public async createRoom(options: ICreateRoomOpts): Promise<string> {
|
||||
const client = await this.prepareClient();
|
||||
return await client.evaluate(async (cli, options) => {
|
||||
const resp = await cli.createRoom(options);
|
||||
const roomId = resp.room_id;
|
||||
const roomPromise = new Promise<void>((resolve) => {
|
||||
const onRoom = (room: Room) => {
|
||||
if (room.roomId === roomId) {
|
||||
cli.off(window.matrixcs.ClientEvent.Room, onRoom);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
cli.on(window.matrixcs.ClientEvent.Room, onRoom);
|
||||
});
|
||||
const { room_id: roomId } = await cli.createRoom(options);
|
||||
if (!cli.getRoom(roomId)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const onRoom = (room: Room) => {
|
||||
if (room.roomId === roomId) {
|
||||
cli.off(window.matrixcs.ClientEvent.Room, onRoom);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
cli.on(window.matrixcs.ClientEvent.Room, onRoom);
|
||||
});
|
||||
await roomPromise;
|
||||
}
|
||||
return roomId;
|
||||
}, options);
|
||||
@@ -439,11 +444,14 @@ export class Client {
|
||||
* @param type The type of account data to set
|
||||
* @param content The content to set
|
||||
*/
|
||||
public async setAccountData(type: string, content: IContent): Promise<void> {
|
||||
public async setAccountData<T extends keyof AccountDataEvents>(
|
||||
type: T,
|
||||
content: AccountDataEvents[T],
|
||||
): Promise<void> {
|
||||
const client = await this.prepareClient();
|
||||
return client.evaluate(
|
||||
async (client, { type, content }) => {
|
||||
await client.setAccountData(type, content);
|
||||
await client.setAccountData(type as T, content as AccountDataEvents[T]);
|
||||
},
|
||||
{ type, content },
|
||||
);
|
||||
|
||||
@@ -6,19 +6,22 @@ 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, Request } from "@playwright/test";
|
||||
import type { Page, Request, Route } from "@playwright/test";
|
||||
import type { Client } from "./client";
|
||||
|
||||
/**
|
||||
* Utility class to simulate offline mode by blocking all requests to the homeserver.
|
||||
* Will not affect any requests before `setupRoute` is called,
|
||||
* which happens implicitly using the goOffline/goOnline methods.
|
||||
*/
|
||||
export class Network {
|
||||
private isOffline = false;
|
||||
private readonly setupPromise: Promise<void>;
|
||||
private setupPromise?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private page: Page,
|
||||
private client: Client,
|
||||
) {
|
||||
this.setupPromise = this.setupRoute();
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Checks if the request is from the client associated with this network object.
|
||||
@@ -30,25 +33,47 @@ export class Network {
|
||||
return authHeader === `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
private async setupRoute() {
|
||||
await this.page.route("**/_matrix/**", async (route) => {
|
||||
if (this.isOffline && (await this.isRequestFromOurClient(route.request()))) {
|
||||
route.abort();
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
private handler = async (route: Route) => {
|
||||
if (this.isOffline && (await this.isRequestFromOurClient(route.request()))) {
|
||||
await route.abort();
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Intercept all /_matrix/ networking requests for client ready to continue/abort them based on offline status
|
||||
* which is set by the goOffline/goOnline methods
|
||||
*/
|
||||
public async setupRoute() {
|
||||
if (!this.setupPromise) {
|
||||
this.setupPromise = this.page.route("**/_matrix/**", this.handler);
|
||||
}
|
||||
await this.setupPromise;
|
||||
}
|
||||
|
||||
// Intercept all /_matrix/ networking requests for client and fail them
|
||||
/**
|
||||
* Cease intercepting all /_matrix/ networking requests for client
|
||||
*/
|
||||
public async destroyRoute() {
|
||||
if (!this.setupPromise) return;
|
||||
await this.page.unroute("**/_matrix/**", this.handler);
|
||||
this.setupPromise = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject all /_matrix/ networking requests for client
|
||||
*/
|
||||
async goOffline(): Promise<void> {
|
||||
await this.setupPromise;
|
||||
await this.setupRoute();
|
||||
this.isOffline = true;
|
||||
}
|
||||
|
||||
// Remove intercept on all /_matrix/ networking requests for this client
|
||||
/**
|
||||
* Continue all /_matrix/ networking requests for this client
|
||||
*/
|
||||
async goOnline(): Promise<void> {
|
||||
await this.setupPromise;
|
||||
await this.setupRoute();
|
||||
this.isOffline = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +140,12 @@ export class Docker {
|
||||
* Detects whether the docker command is actually podman.
|
||||
* To do this, it looks for "podman" in the output of "docker --help".
|
||||
*/
|
||||
static _isPodman?: boolean;
|
||||
static async isPodman(): Promise<boolean> {
|
||||
const { stdout } = await exec("docker", ["--help"], true);
|
||||
return stdout.toLowerCase().includes("podman");
|
||||
if (Docker._isPodman === undefined) {
|
||||
const { stdout } = await exec("docker", ["--help"], true);
|
||||
Docker._isPodman = stdout.toLowerCase().includes("podman");
|
||||
}
|
||||
return Docker._isPodman;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand";
|
||||
// Docker tag to use for synapse docker image.
|
||||
// We target a specific digest as every now and then a Synapse update will break our CI.
|
||||
// This digest is updated by the playwright-image-updates.yaml workflow periodically.
|
||||
const DOCKER_TAG = "develop@sha256:48308e18c5b3ad20bc0d090119618f45b6be4ba727522e37fbf7827d1a109531";
|
||||
const DOCKER_TAG = "develop@sha256:39f94b005e87cd3042c2535c37d8d9f915a88072fe79f6283ac18977fe134321";
|
||||
|
||||
async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> {
|
||||
const templateDir = path.join(__dirname, "templates", opts.template);
|
||||
|
||||
@@ -21,13 +21,15 @@ export class PostgresDocker extends Docker {
|
||||
super();
|
||||
}
|
||||
|
||||
private async waitForPostgresReady(): Promise<void> {
|
||||
private async waitForPostgresReady(ipAddress: string): Promise<void> {
|
||||
const waitTimeMillis = 30000;
|
||||
const startTime = new Date().getTime();
|
||||
let lastErr: Error | null = null;
|
||||
while (new Date().getTime() - startTime < waitTimeMillis) {
|
||||
try {
|
||||
await this.exec(["pg_isready", "-U", "postgres"], true);
|
||||
// Note that we specify the IP address rather than letting it connect to the local
|
||||
// socket: that's the listener we care about and empirically it matters.
|
||||
await this.exec(["pg_isready", "-h", ipAddress, "-U", "postgres"], true);
|
||||
lastErr = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
@@ -57,7 +59,7 @@ export class PostgresDocker extends Docker {
|
||||
const ipAddress = await this.getContainerIp();
|
||||
console.log(new Date(), "postgres container up");
|
||||
|
||||
await this.waitForPostgresReady();
|
||||
await this.waitForPostgresReady(ipAddress);
|
||||
console.log(new Date(), "postgres container ready");
|
||||
return { ipAddress, containerId };
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { BrowserContext, Route } from "@playwright/test";
|
||||
import { getFreePort } from "../utils/port";
|
||||
import { Docker } from "../docker";
|
||||
import { PG_PASSWORD, PostgresDocker } from "../postgres";
|
||||
@@ -24,7 +25,19 @@ export class SlidingSyncProxy {
|
||||
private readonly postgresDocker = new PostgresDocker("sliding-sync");
|
||||
private instance: ProxyInstance;
|
||||
|
||||
constructor(private synapseIp: string) {}
|
||||
constructor(
|
||||
private synapseIp: string,
|
||||
private context: BrowserContext,
|
||||
) {}
|
||||
|
||||
private syncHandler = async (route: Route) => {
|
||||
if (!this.instance) return route.abort("blockedbyclient");
|
||||
|
||||
const baseUrl = `http://localhost:${this.instance.port}`;
|
||||
await route.continue({
|
||||
url: new URL(route.request().url().split("/").slice(3).join("/"), baseUrl).href,
|
||||
});
|
||||
};
|
||||
|
||||
async start(): Promise<ProxyInstance> {
|
||||
console.log(new Date(), "Starting sliding sync proxy...");
|
||||
@@ -50,10 +63,13 @@ export class SlidingSyncProxy {
|
||||
console.log(new Date(), "started!");
|
||||
|
||||
this.instance = { containerId, postgresId, port };
|
||||
await this.context.route("**/_matrix/client/unstable/org.matrix.msc3575/sync*", this.syncHandler);
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.context.unroute("**/_matrix/client/unstable/org.matrix.msc3575/sync*", this.syncHandler);
|
||||
|
||||
await this.postgresDocker.stop();
|
||||
await this.proxyDocker.stop();
|
||||
console.log(new Date(), "Stopped sliding sync proxy.");
|
||||
|
||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
@@ -19,6 +19,11 @@ ignore.push("/OpenSpotlightPayload.ts");
|
||||
ignore.push("/PinnedMessageBadge.tsx");
|
||||
ignore.push("/editor/mock.ts");
|
||||
ignore.push("DeviceIsolationModeController.ts");
|
||||
ignore.push("urls.ts");
|
||||
ignore.push("/json.ts");
|
||||
ignore.push("/ReleaseAnnouncementStore.ts");
|
||||
ignore.push("/WidgetLayoutStore.ts");
|
||||
ignore.push("/common.ts");
|
||||
|
||||
// We ignore js-sdk by default as it may export for other non element-web projects
|
||||
if (!includeJSSDK) ignore.push("matrix-js-sdk");
|
||||
|
||||
@@ -44,3 +44,11 @@ type DeepReadonlyObject<T> = {
|
||||
};
|
||||
|
||||
export type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];
|
||||
|
||||
/**
|
||||
* Returns a union type of the keys of the input Object type whose values are assignable to the given Item type.
|
||||
* Based on https://stackoverflow.com/a/57862073
|
||||
*/
|
||||
export type Assignable<Object, Item> = {
|
||||
[Key in keyof Object]: Object[Key] extends Item ? Key : never;
|
||||
}[keyof Object];
|
||||
|
||||
2
src/@types/global.d.ts
vendored
@@ -44,6 +44,7 @@ import { IConfigOptions } from "../IConfigOptions";
|
||||
import { MatrixDispatcher } from "../dispatcher/dispatcher";
|
||||
import { DeepReadonly } from "./common";
|
||||
import MatrixChat from "../components/structures/MatrixChat";
|
||||
import { InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore";
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
@@ -117,6 +118,7 @@ declare global {
|
||||
mxPerformanceEntryNames: any;
|
||||
mxUIStore: UIStore;
|
||||
mxSetupEncryptionStore?: SetupEncryptionStore;
|
||||
mxInitialCryptoStore?: InitialCryptoSetupStore;
|
||||
mxRoomScrollStateStore?: RoomScrollStateStore;
|
||||
mxActiveWidgetStore?: ActiveWidgetStore;
|
||||
mxOnRecaptchaLoaded?: () => void;
|
||||
|
||||
13
src/@types/json.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
export type JsonValue = null | string | number | boolean;
|
||||
export type JsonArray = Array<JsonValue | JsonObject | JsonArray>;
|
||||
export interface JsonObject {
|
||||
[key: string]: JsonObject | JsonArray | JsonValue;
|
||||
}
|
||||
export type Json = JsonArray | JsonObject;
|
||||
31
src/@types/matrix-js-sdk.d.ts
vendored
@@ -11,6 +11,8 @@ import type { BLURHASH_FIELD } from "../utils/image-media";
|
||||
import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types";
|
||||
import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types";
|
||||
import type { EncryptedFile } from "matrix-js-sdk/src/types";
|
||||
import type { DeviceClientInformation } from "../utils/device/types.ts";
|
||||
import type { UserWidget } from "../utils/WidgetUtils-types.ts";
|
||||
|
||||
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
|
||||
declare module "matrix-js-sdk/src/types" {
|
||||
@@ -57,6 +59,35 @@ declare module "matrix-js-sdk/src/types" {
|
||||
};
|
||||
}
|
||||
|
||||
export interface AccountDataEvents {
|
||||
// Analytics account data event
|
||||
"im.vector.analytics": {
|
||||
id: string;
|
||||
pseudonymousAnalyticsOptIn?: boolean;
|
||||
};
|
||||
// Device client information account data event
|
||||
[key: `io.element.matrix_client_information.${string}`]: DeviceClientInformation;
|
||||
// Element settings account data events
|
||||
"im.vector.setting.breadcrumbs": { recent_rooms: string[] };
|
||||
"io.element.recent_emoji": { recent_emoji: string[] };
|
||||
"im.vector.setting.integration_provisioning": { enabled: boolean };
|
||||
"im.vector.riot.breadcrumb_rooms": { recent_rooms: string[] };
|
||||
"im.vector.web.settings": Record<string, any>;
|
||||
|
||||
// URL preview account data event
|
||||
"org.matrix.preview_urls": { disable: boolean };
|
||||
|
||||
// This is not yet in the Matrix spec yet is being used as if it was
|
||||
"m.widgets": {
|
||||
[widgetId: string]: UserWidget;
|
||||
};
|
||||
|
||||
// This is not in the Matrix spec yet seems to use an `m.` prefix
|
||||
"m.accepted_terms": {
|
||||
accepted: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AudioContent {
|
||||
// MSC1767 + Ideals of MSC2516 as MSC3245
|
||||
// https://github.com/matrix-org/matrix-doc/pull/3245
|
||||
|
||||
18
src/@types/png-chunks-extract.d.ts
vendored
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 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 "png-chunks-extract" {
|
||||
interface IChunk {
|
||||
name: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
function extractPngChunks(data: Uint8Array): IChunk[];
|
||||
|
||||
export default extractPngChunks;
|
||||
}
|
||||
15
src/@types/sanitize-html.d.ts
vendored
@@ -1,15 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 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 sanitizeHtml from "sanitize-html";
|
||||
|
||||
export interface IExtendedSanitizeOptions extends sanitizeHtml.IOptions {
|
||||
// This option only exists in 2.x RCs so far, so not yet present in the
|
||||
// separate type definition module.
|
||||
nestingLimit?: number;
|
||||
}
|
||||
@@ -7,59 +7,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { AuthDict, CrossSigningKeys, MatrixClient, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix";
|
||||
import { AuthDict, MatrixClient, MatrixError, UIAResponse } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
|
||||
import Modal from "./Modal";
|
||||
import { _t } from "./languageHandler";
|
||||
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
|
||||
|
||||
/**
|
||||
* Determine if the homeserver allows uploading device keys with only password auth.
|
||||
* @param cli The Matrix Client to use
|
||||
* @returns True if the homeserver allows uploading device keys with only password auth, otherwise false
|
||||
*/
|
||||
async function canUploadKeysWithPasswordOnly(cli: MatrixClient): Promise<boolean> {
|
||||
try {
|
||||
await cli.uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys);
|
||||
// We should never get here: the server should always require
|
||||
// UI auth to upload device signing keys. If we do, we upload
|
||||
// no keys which would be a no-op.
|
||||
logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
|
||||
logger.log("uploadDeviceSigningKeys advertised no flows!");
|
||||
return false;
|
||||
}
|
||||
const canUploadKeysWithPasswordOnly = error.data.flows.some((f: UIAFlow) => {
|
||||
return f.stages.length === 1 && f.stages[0] === "m.login.password";
|
||||
});
|
||||
return canUploadKeysWithPasswordOnly;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that cross signing keys are created and uploaded for the user.
|
||||
* The homeserver may require user-interactive auth to upload the keys, in
|
||||
* which case the user will be prompted to authenticate. If the homeserver
|
||||
* allows uploading keys with just an account password and one is provided,
|
||||
* the keys will be uploaded without user interaction.
|
||||
* which case the user will be prompted to authenticate.
|
||||
*
|
||||
* This function does not set up backups of the created cross-signing keys
|
||||
* (or message keys): the cross-signing keys are stored locally and will be
|
||||
* lost requiring a crypto reset, if the user logs out or loses their session.
|
||||
*
|
||||
* @param cli The Matrix Client to use
|
||||
* @param isTokenLogin True if the user logged in via a token login, otherwise false
|
||||
* @param accountPassword The password that the user logged in with
|
||||
*/
|
||||
export async function createCrossSigning(
|
||||
cli: MatrixClient,
|
||||
isTokenLogin: boolean,
|
||||
accountPassword?: string,
|
||||
): Promise<void> {
|
||||
export async function createCrossSigning(cli: MatrixClient): Promise<void> {
|
||||
const cryptoApi = cli.getCrypto();
|
||||
if (!cryptoApi) {
|
||||
throw new Error("No crypto API found!");
|
||||
@@ -68,19 +34,14 @@ export async function createCrossSigning(
|
||||
const doBootstrapUIAuth = async (
|
||||
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
|
||||
): Promise<void> => {
|
||||
if (accountPassword && (await canUploadKeysWithPasswordOnly(cli))) {
|
||||
await makeRequest({
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: cli.getUserId(),
|
||||
},
|
||||
password: accountPassword,
|
||||
});
|
||||
} else if (isTokenLogin) {
|
||||
// We are hoping the grace period is active
|
||||
try {
|
||||
await makeRequest({});
|
||||
} else {
|
||||
} catch (error) {
|
||||
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
|
||||
// Not a UIA response
|
||||
throw error;
|
||||
}
|
||||
|
||||
const dialogAesthetics = {
|
||||
[SSOAuthEntry.PHASE_PREAUTH]: {
|
||||
title: _t("auth|uia|sso_title"),
|
||||
|
||||
@@ -295,21 +295,29 @@ export default class DeviceListener {
|
||||
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
|
||||
|
||||
// cross signing isn't enabled - nag to enable it
|
||||
// There are 2 different toasts for:
|
||||
// There are 3 different toasts for:
|
||||
if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) {
|
||||
// Cross-signing on account but this device doesn't trust the master key (verify this session)
|
||||
// Toast 1. Cross-signing on account but this device doesn't trust the master key (verify this session)
|
||||
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
|
||||
this.checkKeyBackupStatus();
|
||||
} else {
|
||||
// No cross-signing or key backup on account (set up encryption)
|
||||
await cli.waitForClientWellKnown();
|
||||
if (isSecureBackupRequired(cli) && isLoggedIn()) {
|
||||
// If we're meant to set up, and Secure Backup is required,
|
||||
// trigger the flow directly without a toast once logged in.
|
||||
hideSetupEncryptionToast();
|
||||
accessSecretStorage();
|
||||
const backupInfo = await this.getKeyBackupInfo();
|
||||
if (backupInfo) {
|
||||
// Toast 2: Key backup is enabled but recovery (4S) is not set up: prompt user to set up recovery.
|
||||
// Since we now enable key backup at registration time, this will be the common case for
|
||||
// new users.
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
|
||||
} else {
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
|
||||
// Toast 3: No cross-signing or key backup on account (set up encryption)
|
||||
await cli.waitForClientWellKnown();
|
||||
if (isSecureBackupRequired(cli) && isLoggedIn()) {
|
||||
// If we're meant to set up, and Secure Backup is required,
|
||||
// trigger the flow directly without a toast once logged in.
|
||||
hideSetupEncryptionToast();
|
||||
accessSecretStorage();
|
||||
} else {
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { LegacyRef, ReactNode } from "react";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import sanitizeHtml, { IOptions } from "sanitize-html";
|
||||
import classNames from "classnames";
|
||||
import katex from "katex";
|
||||
import { decode } from "html-entities";
|
||||
@@ -19,7 +19,6 @@ import { Optional } from "matrix-events-sdk";
|
||||
import escapeHtml from "escape-html";
|
||||
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
|
||||
|
||||
import { IExtendedSanitizeOptions } from "./@types/sanitize-html";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
|
||||
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
|
||||
@@ -126,7 +125,7 @@ export function isUrlPermitted(inputUrl: string): boolean {
|
||||
}
|
||||
|
||||
// this is the same as the above except with less rewriting
|
||||
const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
|
||||
const composerSanitizeHtmlParams: IOptions = {
|
||||
...sanitizeHtmlParams,
|
||||
transformTags: {
|
||||
"code": transformTags["code"],
|
||||
@@ -135,7 +134,7 @@ const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
|
||||
};
|
||||
|
||||
// reduced set of allowed tags to avoid turning topics into Myspace
|
||||
const topicSanitizeHtmlParams: IExtendedSanitizeOptions = {
|
||||
const topicSanitizeHtmlParams: IOptions = {
|
||||
...sanitizeHtmlParams,
|
||||
allowedTags: [
|
||||
"font", // custom to matrix for IRC-style font coloring
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactElement } from "react";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import sanitizeHtml, { IOptions } from "sanitize-html";
|
||||
import { merge } from "lodash";
|
||||
import _Linkify from "linkify-react";
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ELEMENT_URL_PATTERN,
|
||||
options as linkifyMatrixOptions,
|
||||
} from "./linkify-matrix";
|
||||
import { IExtendedSanitizeOptions } from "./@types/sanitize-html";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
@@ -26,7 +25,7 @@ import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
|
||||
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
||||
|
||||
export const transformTags: NonNullable<IExtendedSanitizeOptions["transformTags"]> = {
|
||||
export const transformTags: NonNullable<IOptions["transformTags"]> = {
|
||||
// custom to matrix
|
||||
// add blank targets to all hyperlinks except vector URLs
|
||||
"a": function (tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
@@ -137,7 +136,7 @@ export const transformTags: NonNullable<IExtendedSanitizeOptions["transformTags"
|
||||
},
|
||||
};
|
||||
|
||||
export const sanitizeHtmlParams: IExtendedSanitizeOptions = {
|
||||
export const sanitizeHtmlParams: IOptions = {
|
||||
allowedTags: [
|
||||
// These tags are suggested by the spec https://spec.matrix.org/v1.10/client-server-api/#mroommessage-msgtypes
|
||||
"font", // custom to matrix for IRC-style font coloring
|
||||
|
||||
@@ -176,7 +176,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
|
||||
url: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: string;
|
||||
size: number;
|
||||
} | null {
|
||||
// We do no caching here because the SDK caches setting
|
||||
// and the browser will cache the sound.
|
||||
|
||||