diff --git a/.eslintrc.js b/.eslintrc.js index f0720a025253..342dbde484ad 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -169,7 +169,7 @@ module.exports = { }, overrides: [ { - files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "cypress/**/*.ts", "playwright/**/*.ts"], + files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "playwright/**/*.ts"], extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"], rules: { "@typescript-eslint/explicit-function-return-type": [ @@ -233,14 +233,14 @@ module.exports = { }, }, { - files: ["test/**/*.{ts,tsx}", "cypress/**/*.ts", "playwright/**/*.ts"], + files: ["test/**/*.{ts,tsx}", "playwright/**/*.ts"], extends: ["plugin:matrix-org/jest"], rules: { // We don't need super strict typing in test utilities "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-member-accessibility": "off", - // Jest/Cypress specific + // Jest/Playwright specific // Disabled tests are a reality for now but as soon as all of the xits are // eliminated, we should enforce this. @@ -255,29 +255,11 @@ module.exports = { ], }, }, - { - files: ["cypress/**/*.ts"], - parserOptions: { - project: ["./cypress/tsconfig.json"], - }, - rules: { - // Cypress "promises" work differently - disable some related rules - "jest/valid-expect": "off", - "jest/valid-expect-in-promise": "off", - "jest/no-done-callback": "off", - }, - }, { files: ["playwright/**/*.ts"], parserOptions: { project: ["./playwright/tsconfig.json"], }, - rules: { - // Cypress "promises" work differently - disable some related rules - "jest/valid-expect": "off", - "jest/valid-expect-in-promise": "off", - "jest/no-done-callback": "off", - }, }, ], settings: { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b436b4e9013c..91764ba7f5ae 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,14 @@ -* @matrix-org/element-web -/.github/workflows/** @matrix-org/element-web-app-team -/package.json @matrix-org/element-web-app-team -/yarn.lock @matrix-org/element-web-app-team +* @matrix-org/element-web-reviewers +/.github/workflows/** @matrix-org/element-web-team +/package.json @matrix-org/element-web-team +/yarn.lock @matrix-org/element-web-team + +/src/SecurityManager.ts @matrix-org/element-crypto-web-reviewers +/test/SecurityManager-test.ts @matrix-org/element-crypto-web-reviewers +/src/async-components/views/dialogs/security/ @matrix-org/element-crypto-web-reviewers +/src/components/views/dialogs/security/ @matrix-org/element-crypto-web-reviewers +/test/components/views/dialogs/security/ @matrix-org/element-crypto-web-reviewers +/src/stores/SetupEncryptionStore.ts @matrix-org/element-crypto-web-reviewers +/test/stores/SetupEncryptionStore-test.ts @matrix-org/element-crypto-web-reviewers + /src/i18n/strings diff --git a/.github/workflows/cypress.yaml b/.github/workflows/cypress.yaml deleted file mode 100644 index ca6f38abfec9..000000000000 --- a/.github/workflows/cypress.yaml +++ /dev/null @@ -1,231 +0,0 @@ -# Triggers after the layered build has finished, taking the artifact and running cypress on it -# -# Also called by a workflow in matrix-js-sdk. -# -name: Cypress End to End Tests -on: - workflow_run: - workflows: ["Element Web - Build"] - types: - - completed - - # support calls from other workflows - workflow_call: - inputs: - react-sdk-repository: - type: string - required: true - description: "The name of the github repository to check out and build." - secrets: - KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_RUST: - required: true - KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_LEGACY: - required: true - TCMS_USERNAME: - required: true - TCMS_PASSWORD: - required: true - -concurrency: - group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} - cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }} - -jobs: - prepare: - name: Prepare - if: github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - permissions: - actions: read - issues: read - statuses: write - pull-requests: read - outputs: - uuid: ${{ steps.uuid.outputs.value }} - pr_id: ${{ steps.prdetails.outputs.pr_id }} - percy_enable: ${{ steps.percy.outputs.value || '0' }} - steps: - # We create the status here and then update it to success/failure in the `report` stage - # This provides an easy link to this workflow_run from the PR before Cypress is done. - - uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1 - with: - authToken: ${{ secrets.GITHUB_TOKEN }} - state: pending - context: ${{ github.workflow }} / cypress - sha: ${{ github.event.workflow_run.head_sha }} - target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - - - id: prdetails - if: github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.event == 'merge_group' - uses: matrix-org/pr-details-action@v1.3 - with: - owner: ${{ github.event.workflow_run.head_repository.owner.login }} - branch: ${{ github.event.workflow_run.head_branch }} - - # Percy is disabled while we're figuring out https://github.com/vector-im/wat-internal/issues/36 - # and https://github.com/vector-im/wat-internal/issues/56. We're hoping to turn it back on or switch - # to an alternative in the future. - # # Only run Percy when it is demanded or we are running the daily build - # - name: Enable Percy - # id: percy - # if: | - # github.event.workflow_run.event == 'schedule' || - # ( - # github.event.workflow_run.event == 'merge_group' && - # contains(fromJSON(steps.prdetails.outputs.data).labels.*.name, 'X-Needs-Percy') - # ) - # run: echo "value=1" >> $GITHUB_OUTPUT - - - name: Generate unique ID 💎 - id: uuid - run: echo "value=sha-$GITHUB_SHA-time-$(date +"%s")" >> $GITHUB_OUTPUT - - tests: - name: "Run Tests (${{ matrix.crypto }} crypto)" - needs: prepare - runs-on: ubuntu-latest - permissions: - actions: read - issues: read - pull-requests: read - environment: Cypress - strategy: - fail-fast: false - matrix: - # Run tests using both crypto stacks - crypto: [legacy, rust] - ci_node_total: [3] - ci_node_index: [0, 1, 2] - steps: - # The version of chrome shipped by default may not be consistent across runners - # so we explicitly use a specific version of chrome here. - - uses: browser-actions/setup-chrome@803ef6dfb4fdf22089c9563225d95e4a515820a0 # v1 - - run: echo "BROWSER_PATH=$(which chrome)" >> $GITHUB_ENV - - # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action - # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: - - name: 📥 Download artifact - uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2 - with: - run_id: ${{ github.event.workflow_run.id }} - name: previewbuild - path: webapp - - # The workflow_run.head_sha is the sha of the head commit but the element-web was built using a simulated - # merge commit - https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request - # so use the sha from the tarball for the checkout of the cypress tests - # to make sure we get a matching set of code and tests. - - name: Grab sha from webapp - id: sha - run: | - echo "sha=$(cat webapp/sha)" >> $GITHUB_OUTPUT - - - uses: actions/checkout@v4 - with: - # XXX: We're checking out untrusted code in a secure context - # We need to be careful to not trust anything this code outputs/may do - # - # Note that (in the absence of a `react-sdk-repository` input), - # we check out from the default repository, which is (for this workflow) the - # *target* repository for the pull request. - # - ref: ${{ steps.sha.outputs.sha }} - persist-credentials: false - path: matrix-react-sdk - repository: ${{ inputs.react-sdk-repository || github.repository }} - - # Enable rust crypto if the calling workflow requests it - - name: Enable rust crypto - if: matrix.crypto == 'rust' - run: | - echo "CYPRESS_RUST_CRYPTO=1" >> "$GITHUB_ENV" - - - name: Run Cypress tests via knapsack pro - uses: cypress-io/github-action@ebe8b24c4428922d0f793a5c4c96853a633180e3 # v6.6.0 - with: - working-directory: matrix-react-sdk - headed: true - start: npx serve -p 8080 -L ../webapp - wait-on: "http://localhost:8080" - record: false - parallel: false - # The built-in Electron runner seems to grind to a halt trying to run the tests, so use chrome. - command: yarn percy exec --parallel -- npx knapsack-pro-cypress --config trashAssetsBeforeRuns=false --browser ${{ env.BROWSER_PATH }} - env: - # Knapsack token and config - KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS: ${{ matrix.crypto == 'rust' && secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_RUST || secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_LEGACY }} - KNAPSACK_PRO_CI_NODE_TOTAL: ${{ matrix.ci_node_total }} - KNAPSACK_PRO_CI_NODE_INDEX: ${{ matrix.ci_node_index }} - KNAPSACK_PRO_TEST_FILE_PATTERN: cypress/e2e/**/*.spec.ts - KNAPSACK_PRO_BRANCH: ${{ github.event.workflow_run.head_branch }} - KNAPSACK_PRO_COMMIT_HASH: ${{ github.event.workflow_run.head_sha }} - - # Use existing chromium rather than downloading another - PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true - - # pass GitHub token to allow accurately detecting a build vs a re-run build - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # make Node's os.tmpdir() return something where we actually have permissions - TMPDIR: ${{ runner.temp }} - - # pass the Percy token as an environment variable - PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} - - # only run percy on legacy crypto (for now) - PERCY_ENABLE: ${{ matrix.crypto == 'legacy' && needs.prepare.outputs.percy_enable || 0 }} - PERCY_BROWSER_EXECUTABLE: ${{ steps.setup-chrome.outputs.chrome-path }} - # tell Percy more details about the context of this run - PERCY_BRANCH: ${{ github.event.workflow_run.head_branch }} - PERCY_COMMIT: ${{ github.event.workflow_run.head_sha }} - PERCY_PULL_REQUEST: ${{ needs.prepare.outputs.pr_id }} - PERCY_PARALLEL_NONCE: ${{ needs.prepare.outputs.uuid }} - # We manually finalize the build in the report stage - PERCY_PARALLEL_TOTAL: -1 - - - name: 📤 Upload results artifact - if: failure() - uses: actions/upload-artifact@v3 - with: - name: cypress-results-${{ matrix.crypto }}-crypto - path: | - matrix-react-sdk/cypress/screenshots - matrix-react-sdk/cypress/videos - matrix-react-sdk/cypress/synapselogs - matrix-react-sdk/cypress/results/cypresslogs - - report: - name: Finalize results - needs: - - prepare - - tests - runs-on: ubuntu-latest - if: always() - permissions: - statuses: write - steps: - - name: Finalize Percy - if: needs.prepare.outputs.percy_enable == '1' - run: npx -p @percy/cli percy build:finalize - env: - PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} - PERCY_PARALLEL_NONCE: ${{ needs.prepare.outputs.uuid }} - - - name: Skip Percy required check - if: needs.prepare.outputs.percy_enable != '1' - uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1 - with: - authToken: ${{ secrets.GITHUB_TOKEN }} - state: success - description: Percy skipped - context: percy/matrix-react-sdk - sha: ${{ github.event.workflow_run.head_sha }} - target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - - - uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1 - with: - authToken: ${{ secrets.GITHUB_TOKEN }} - state: ${{ needs.tests.result == 'success' && 'success' || 'failure' }} - context: ${{ github.workflow }} / cypress - sha: ${{ github.event.workflow_run.head_sha }} - target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/element-web.yaml b/.github/workflows/element-web.yaml index 7b085d892dfe..8ac5e2da9426 100644 --- a/.github/workflows/element-web.yaml +++ b/.github/workflows/element-web.yaml @@ -3,12 +3,6 @@ # as an artifact and run integration tests. name: Element Web - Build on: - # We only need the nightly run for Percy which is disabled while we're - # figuring out https://github.com/vector-im/wat-internal/issues/36 and - # https://github.com/vector-im/wat-internal/issues/56. We're hoping to - # turn it back on or switch to an alternative in the future. - # schedule: - # - cron: "17 4 * * 1-5" # every weekday at 04:17 UTC pull_request: {} merge_group: types: [checks_requested] @@ -29,6 +23,10 @@ on: type: string required: false description: "The Git SHA of matrix-js-sdk to build against. By default, will use a matching branch name if it exists, or develop." + element-web-sha: + type: string + required: false + description: "The Git SHA of element-web to build against. By default, will use a matching branch name if it exists, or develop." concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} @@ -55,8 +53,9 @@ jobs: - name: Fetch layered build id: layered_build env: - # tell layered.sh to check out the right sha of the JS-SDK, if we were given one + # tell layered.sh to check out the right sha of the JS-SDK & EW, if they were given one JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} + ELEMENT_WEB_GITHUB_BASE_REF: ${{ inputs.element-web-sha }} run: | scripts/ci/layered.sh JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD) @@ -77,13 +76,13 @@ jobs: echo $VERSION > webapp/version working-directory: ./element-web - # Record the react-sdk sha so our cypress tests are from the same sha + # Record the react-sdk sha so our Playwright tests are from the same sha - name: Record react-sdk SHA run: | git rev-parse HEAD > element-web/webapp/sha - name: Upload Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: previewbuild path: element-web/webapp diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index c054d1cb4606..51471dd10787 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -13,6 +13,9 @@ on: type: string required: true description: "The name of the github repository to check out and build." + secrets: + ELEMENT_BOT_TOKEN: + required: true concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} @@ -49,14 +52,13 @@ jobs: fail-fast: false matrix: # Run multiple instances in parallel to speed up the tests - runner: [1, 2, 3, 4, 5, 6] + runner: [1, 2, 3, 4, 5, 6, 7, 8] steps: - # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action - # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: - name: 📥 Download artifact - uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2 + uses: actions/download-artifact@v4 with: - run_id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} name: previewbuild path: webapp @@ -93,7 +95,9 @@ jobs: run: yarn install --frozen-lockfile - name: Get installed Playwright version - run: echo "PLAYWRIGHT_VERSION=$(yarn info @playwright/test -A --json | jq -r .data.version)" >> $GITHUB_ENV + id: playwright + working-directory: matrix-react-sdk + run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT - name: Cache playwright binaries uses: actions/cache@v3 @@ -101,7 +105,7 @@ jobs: with: path: | ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }} - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' @@ -116,9 +120,9 @@ jobs: - name: Upload blob report to GitHub Actions Artifacts if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: all-blob-reports + name: all-blob-reports-${{ matrix.runner }} path: matrix-react-sdk/blob-report retention-days: 1 @@ -145,16 +149,17 @@ jobs: run: yarn install --frozen-lockfile - name: Download blob reports from GitHub Actions Artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: all-blob-reports + pattern: all-blob-reports-* path: all-blob-reports + merge-multiple: true - name: Merge into HTML Report run: yarn playwright merge-reports --reporter=html,github ./all-blob-reports - name: Upload HTML report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: html-report--attempt-${{ github.run_attempt }} path: playwright-report diff --git a/.github/workflows/netlify.yaml b/.github/workflows/netlify.yaml index fef2a3e917d8..ea393830d695 100644 --- a/.github/workflows/netlify.yaml +++ b/.github/workflows/netlify.yaml @@ -24,12 +24,11 @@ jobs: Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts. - # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action - # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: - name: 📥 Download artifact - uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2 + uses: actions/download-artifact@v4 with: - run_id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} name: previewbuild path: webapp diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 1293e6e6656c..d9b26c78e872 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -10,7 +10,10 @@ concurrency: jobs: sonarqube: name: 🩻 SonarQube - if: github.event.workflow_run.event != 'merge_group' + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group' uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + with: + sharded: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a3808c4295f7..c92f6ede247c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,11 @@ jobs: jest: name: Jest runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Run multiple instances in parallel to speed up the tests + runner: [1, 2] steps: - name: Checkout code uses: actions/checkout@v4 @@ -62,13 +67,23 @@ jobs: --coverage=${{ env.ENABLE_COVERAGE }} \ --ci \ --max-workers ${{ steps.cpu-cores.outputs.count }} \ + --shard ${{ matrix.runner }}/${{ strategy.job-total }} \ --cacheDirectory /tmp/jest_cache + env: + JEST_SONAR_UNIQUE_OUTPUT_NAME: true + + # tell jest to use coloured output + FORCE_COLOR: true + + - name: Move coverage files into place + if: env.ENABLE_COVERAGE == 'true' + run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.runner }}.lcov.info - name: Upload Artifact if: env.ENABLE_COVERAGE == 'true' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: coverage + name: coverage-${{ matrix.runner }} path: | coverage !coverage/lcov-report diff --git a/.gitignore b/.gitignore index ecab8be95f76..3137cd555b12 100644 --- a/.gitignore +++ b/.gitignore @@ -19,14 +19,3 @@ package-lock.json .vscode .vscode/ - -/cypress/videos -/cypress/downloads -/cypress/screenshots -/cypress/synapselogs -/cypress/dendritelogs -/cypress/results - -# These could have files in them but don't currently -# Cypress will still auto-create them though... -/cypress/performance diff --git a/.percy.yml b/.percy.yml deleted file mode 100644 index 78238924233a..000000000000 --- a/.percy.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -snapshot: - widths: - - 1024 - - 1920 -percy: - defer-uploads: true diff --git a/.prettierignore b/.prettierignore index 072361a37de8..00556f1c2696 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,3 +17,6 @@ yarn.lock # This file is owned, parsed, and generated by allchange, which doesn't comply with prettier /CHANGELOG.md + +# This file is also machine-generated +/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 378eb1fa8540..6092159b1287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,60 @@ +Changes in [3.90.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.90.0) (2024-01-19) +===================================================================================================== +## ✨ Features + +* Broaden support for matrix spec versions ([#12159](https://github.com/matrix-org/matrix-react-sdk/pull/12159)). Contributed by @RiotRobot. + +## 🐛 Bug Fixes + +* Fixed shield alignment on message Input ([#12155](https://github.com/matrix-org/matrix-react-sdk/pull/12155)). Contributed by @RiotRobot. + + +Changes in [3.89.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.89.0) (2024-01-16) +===================================================================================================== +## ✨ Features + +* Accessibility improvements around aria-labels and tooltips ([#12062](https://github.com/matrix-org/matrix-react-sdk/pull/12062)). Contributed by @t3chguy. +* Add RoomKnocksBar to RoomHeader ([#12077](https://github.com/matrix-org/matrix-react-sdk/pull/12077)). Contributed by @charlynguyen. +* Adjust tooltip side for DecoratedRoomAvatar to not obscure room name ([#12079](https://github.com/matrix-org/matrix-react-sdk/pull/12079)). Contributed by @t3chguy. +* Iterate landmarks around the app in order to improve a11y ([#12064](https://github.com/matrix-org/matrix-react-sdk/pull/12064)). Contributed by @t3chguy. +* Update element call embedding UI ([#12056](https://github.com/matrix-org/matrix-react-sdk/pull/12056)). Contributed by @toger5. +* Use Compound tooltips instead of homegrown in TextWithTooltip \& InfoTooltip ([#12052](https://github.com/matrix-org/matrix-react-sdk/pull/12052)). Contributed by @t3chguy. + +## 🐛 Bug Fixes + +* Fix regression around CSS stacking contexts and PIP widgets ([#12094](https://github.com/matrix-org/matrix-react-sdk/pull/12094)). Contributed by @t3chguy. +* Fix Identity Server terms accepting not working as expected ([#12109](https://github.com/matrix-org/matrix-react-sdk/pull/12109)). Contributed by @t3chguy. +* fix: microphone and camera dropdown doesn't work In legacy call ([#12105](https://github.com/matrix-org/matrix-react-sdk/pull/12105)). Contributed by @muratersin. +* Revert "Set up key backup using non-deprecated APIs (#12005)" ([#12102](https://github.com/matrix-org/matrix-react-sdk/pull/12102)). Contributed by @BillCarsonFr. +* Fix regression around read receipt animation from refs changes ([#12100](https://github.com/matrix-org/matrix-react-sdk/pull/12100)). Contributed by @t3chguy. +* Added meaning full error message based on platform ([#12074](https://github.com/matrix-org/matrix-react-sdk/pull/12074)). Contributed by @Pankaj-SinghR. +* Fix editing event from search room view ([#11992](https://github.com/matrix-org/matrix-react-sdk/pull/11992)). Contributed by @t3chguy. +* Fix timeline position when moving to a room and coming back ([#12055](https://github.com/matrix-org/matrix-react-sdk/pull/12055)). Contributed by @florianduros. +* Fix threaded reply playwright tests ([#12070](https://github.com/matrix-org/matrix-react-sdk/pull/12070)). Contributed by @dbkr. +* Element-R: fix repeated requests to enter 4S key during cross-signing reset ([#12059](https://github.com/matrix-org/matrix-react-sdk/pull/12059)). Contributed by @richvdh. +* Fix position of thumbnail in room timeline ([#12016](https://github.com/matrix-org/matrix-react-sdk/pull/12016)). Contributed by @anoopw3bdev. + + +Changes in [3.88.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.88.0) (2024-01-04) +===================================================================================================== +## 🐛 Bug Fixes + +* Fix a fresh login creating a new key backup ([#12106](https://github.com/matrix-org/matrix-react-sdk/pull/12106)). + +Changes in [3.87.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.87.0) (2023-12-19) +===================================================================================================== +## ✨ Features + +* Keep more recent rageshake logs ([#12003](https://github.com/matrix-org/matrix-react-sdk/pull/12003)). Contributed by @richvdh. + +## 🐛 Bug Fixes + +* Fix bug which prevented correct clean up of rageshake store ([#12002](https://github.com/matrix-org/matrix-react-sdk/pull/12002)). Contributed by @richvdh. +* Set up key backup using non-deprecated APIs ([#12005](https://github.com/matrix-org/matrix-react-sdk/pull/12005)). Contributed by @andybalaam. +* Fix notifications appearing for old events ([#3946](https://github.com/matrix-org/matrix-js-sdk/pull/3946)). Contributed by @dbkr. +* Prevent phantom notifications from events not in a room's timeline ([#3942](https://github.com/matrix-org/matrix-js-sdk/pull/3942)). Contributed by @dbkr. + + Changes in [3.86.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.86.0) (2023-12-05) ===================================================================================================== ## 🦖 Deprecations diff --git a/README.md b/README.md index 4569a1f8fe15..f3f34939a962 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ [![npm](https://img.shields.io/npm/v/matrix-react-sdk)](https://www.npmjs.com/package/matrix-react-sdk) ![Tests](https://github.com/matrix-org/matrix-react-sdk/actions/workflows/tests.yml/badge.svg) +[![Playwright](https://img.shields.io/badge/Playwright-end_to_end_tests-blue)](https://e2e-develop--matrix-react-sdk.netlify.app/) ![Static Analysis](https://github.com/matrix-org/matrix-react-sdk/actions/workflows/static_analysis.yaml/badge.svg) -[![Knapsack Pro Parallel CI builds for Cypress Test - Legacy Crypto](https://img.shields.io/badge/Knapsack%20Pro-Parallel%20%2F%20Cypress%20Test%20--%20Legacy%20Crypto-%230074ff)](https://knapsackpro.com/dashboard/organizations/3882/projects/2469/test_suites/3724/builds?utm_campaign=organization-id-3882&utm_content=test-suite-id-3724&utm_medium=readme&utm_source=knapsack-pro-badge&utm_term=project-id-2469) -[![Knapsack Pro Parallel CI builds for Cypress Test - Rust Crypto](https://img.shields.io/badge/Knapsack%20Pro-Parallel%20%2F%20Cypress%20Test%20--%20Rust%20Crypto-%230074ff)](https://knapsackpro.com/dashboard/organizations/3882/projects/2469/test_suites/3729/builds?utm_campaign=organization-id-3882&utm_content=test-suite-id-3729&utm_medium=readme&utm_source=knapsack-pro-badge&utm_term=project-id-2469) [![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement-web%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element-web) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=coverage)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) @@ -205,5 +204,5 @@ Now the yarn commands should work as normal. ### End-to-End tests -We use Cypress and Element Web for end-to-end tests. See -[`docs/cypress.md`](docs/cypress.md) for more information. +We use Playwright and Element Web for end-to-end tests. See +[`docs/playwright.md`](docs/playwright.md) for more information. diff --git a/cypress-ci-reporter-config.json b/cypress-ci-reporter-config.json deleted file mode 100644 index e9f1330a6f1f..000000000000 --- a/cypress-ci-reporter-config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "reporterEnabled": "spec, mocha-junit-reporter", - "mochaJunitReporterReporterOptions": { - "mochaFile": "cypress/results/junit/results-[hash].xml", - "useFullSuiteTitle": true - } -} diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index 5351e6d686b8..000000000000 --- a/cypress.config.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { defineConfig } from "cypress"; - -import registerPlugins from "./cypress/plugins"; - -export default defineConfig({ - videoUploadOnPasses: false, - projectId: "ppvnzg", - experimentalInteractiveRunEvents: true, - experimentalMemoryManagement: true, - defaultCommandTimeout: 10000, - chromeWebSecurity: false, - e2e: { - setupNodeEvents(on, config) { - return registerPlugins(on, config); - }, - baseUrl: "http://localhost:8080", - specPattern: "cypress/e2e/**/*.spec.{js,jsx,ts,tsx}", - }, - env: { - // Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image. - SLIDING_SYNC_PROXY_TAG: "v0.99.3", - HOMESERVER: "synapse", - }, - retries: { - runMode: 4, - openMode: 0, - }, - - // disable logging of HTTP requests made to the Cypress server. They are noisy and not very helpful. - // @ts-ignore https://github.com/cypress-io/cypress/issues/26284 - morgan: false, - - // Create XML result files - reporter: "cypress-multi-reporters", - reporterOptions: { - configFile: "cypress-ci-reporter-config.json", - }, -}); diff --git a/cypress/e2e/audio-player/audio-player.spec.ts b/cypress/e2e/audio-player/audio-player.spec.ts deleted file mode 100644 index 30470716c911..000000000000 --- a/cypress/e2e/audio-player/audio-player.spec.ts +++ /dev/null @@ -1,387 +0,0 @@ -/* -Copyright 2023 Suguru Hirahara - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { Layout } from "../../../src/settings/enums/Layout"; - -describe("Audio player", () => { - let homeserver: HomeserverInstance; - const TEST_USER = "Hanako"; - - const percyCSS = - // FIXME: hide mx_SeekBar because flaky - see https://github.com/vector-im/element-web/issues/24898 - ".mx_SeekBar, " + - // Exclude various components from the snapshot, for consistency - ".mx_JumpToBottomButton, " + - ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - - const uploadFile = (file: string) => { - // Upload a file from the message composer - cy.get(".mx_MessageComposer_actions input[type='file']").selectFile(file, { force: true }); - - cy.get(".mx_Dialog").within(() => { - // Find and click primary "Upload" button - cy.findByRole("button", { name: "Upload" }).click(); - }); - - // Wait until the file is sent - cy.get(".mx_RoomView_statusArea_expanded").should("not.exist"); - cy.get(".mx_EventTile.mx_EventTile_last .mx_EventTile_receiptSent").should("exist"); - // wait for the tile to finish loading - cy.get(".mx_AudioPlayer_mediaName").should("exist"); - }; - - /** - * Take snapshots of mx_EventTile_last on each layout, outputting log for reference/debugging. - * @param detail The Percy snapshot name. Used for outputting logs too. - * @param monospace This changes the font used to render the UI from a default one to a monospace one. - * Set to false by default. Note that the font applied to Percy snapshots can be different from the test result - * on your local environment. - */ - const takeSnapshots = (detail: string, monospace = false) => { - // Check that the audio player is rendered and its button becomes visible - const checkPlayerVisibility = () => { - // Assert that the audio player and media information are visible - cy.get(".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container .mx_AudioPlayer_mediaInfo").within( - () => { - cy.contains(".mx_AudioPlayer_mediaName", ".ogg").should("be.visible"); // extension - cy.contains(".mx_AudioPlayer_byline", "00:01").should("be.visible"); - cy.contains(".mx_AudioPlayer_byline", "(3.56 KB)").should("be.visible"); // actual size - }, - ); - - // Assert that the play button can be found and is visible - cy.findByRole("button", { name: "Play" }).should("be.visible"); - - if (monospace) { - // Assert that the monospace timer is visible - cy.get("[role='timer']").should("have.css", "font-family", '"monospace"').should("be.visible"); - } - }; - - /** - * Define snapshot widths of selected EventTile, on which the audio player is rendered - * - * 50px (magic number): narrow enough EventTile to be compressed to check a11y - * 267px: EventTile on IRC and modern/group layout, on which the player is rendered in its full width - * 285px: EventTile on bubble layout, on which the player is rendered in its full width - */ - const snapshotWidthsIRC = [50, 267]; - const snapshotWidthsGroup = snapshotWidthsIRC; - const snapshotWidthsBubble = [50, 285]; - - if (monospace) { - // Enable system font and monospace setting - cy.setSettingValue("useSystemFont", null, SettingLevel.DEVICE, true); - cy.setSettingValue("systemFont", null, SettingLevel.DEVICE, "monospace"); - } - - // Check the status of the seek bar - // TODO: check if visible - currently checking its visibility on a compressed EventTile returns an error - cy.get(".mx_AudioPlayer_seek input[type='range']").should("exist"); - - // Enable IRC layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - - cy.get(".mx_EventTile_last[data-layout='irc']").within(() => { - // Click the event timestamp to highlight EventTile in case it is not visible - cy.get(".mx_MessageTimestamp").click(); - - // Assert that rendering of the player settled and the play button is visible before taking a snapshot - checkPlayerVisibility(); - }); - - // Take a snapshot of mx_EventTile_last on IRC layout - cy.get(".mx_EventTile_last").percySnapshotElement(detail + " on IRC layout", { - percyCSS, - widths: snapshotWidthsIRC, - }); - - // Output a log - cy.log("Took a snapshot of " + detail + " on IRC layout"); - - // Take a snapshot on modern/group layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - cy.get(".mx_EventTile_last[data-layout='group']").within(() => { - cy.get(".mx_MessageTimestamp").click(); - checkPlayerVisibility(); - }); - cy.get(".mx_EventTile_last").percySnapshotElement(detail + " on modern/group layout", { - percyCSS, - widths: snapshotWidthsGroup, - }); - cy.log("Took a snapshot of " + detail + " on modern/group layout"); - - // Take a snapshot on bubble layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_EventTile_last[data-layout='bubble']").within(() => { - cy.get(".mx_MessageTimestamp").click(); - checkPlayerVisibility(); - }); - cy.get(".mx_EventTile_last").percySnapshotElement(detail + " on bubble layout", { - percyCSS, - widths: snapshotWidthsBubble, - }); - cy.log("Took a snapshot of " + detail + " on bubble layout"); - }; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, TEST_USER); - }); - - cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room"); - - // Wait until configuration is finished - cy.get(".mx_GenericEventListSummary[data-layout='group'] .mx_GenericEventListSummary_summary").within(() => { - cy.findByText(TEST_USER + " created and configured the room.").should("exist"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should be correctly rendered - light theme", () => { - uploadFile("cypress/fixtures/1sec-long-name-audio-file.ogg"); - - takeSnapshots("Selected EventTile of audio player (light theme)"); - }); - - it("should be correctly rendered - light theme with monospace font", () => { - uploadFile("cypress/fixtures/1sec-long-name-audio-file.ogg"); - - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 - //takeSnapshots("Selected EventTile of audio player (light theme, monospace font)", true); // Enable monospace - }); - - it("should be correctly rendered - high contrast theme", () => { - // Disable system theme in case ThemeWatcher enables the theme automatically, - // so that the high contrast theme can be enabled - cy.setSettingValue("use_system_theme", null, SettingLevel.DEVICE, false); - - // Enable high contrast manually - cy.openUserSettings("Appearance") - .findByTestId("mx_ThemeChoicePanel") - .findByLabelText("Use high contrast") - .click({ force: true }); // force click because the size of the checkbox is zero - - cy.closeDialog(); - - uploadFile("cypress/fixtures/1sec-long-name-audio-file.ogg"); - - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 - //takeSnapshots("Selected EventTile of audio player (high contrast)"); - }); - - it("should be correctly rendered - dark theme", () => { - // Enable dark theme - cy.setSettingValue("theme", null, SettingLevel.ACCOUNT, "dark"); - - uploadFile("cypress/fixtures/1sec-long-name-audio-file.ogg"); - - takeSnapshots("Selected EventTile of audio player (dark theme)"); - }); - - it("should play an audio file", () => { - uploadFile("cypress/fixtures/1sec.ogg"); - - // Assert that the audio player is rendered - cy.get(".mx_EventTile_last .mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container").within(() => { - // Assert that the counter is zero before clicking the play button - cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); - - // Find and click "Play" button, the wait is to make the test less flaky - cy.findByRole("button", { name: "Play" }).should("exist"); - cy.wait(500).findByRole("button", { name: "Play" }).click(); - - // Assert that "Pause" button can be found - cy.findByRole("button", { name: "Pause" }).should("exist"); - - // Assert that the timer is reset when the audio file finished playing - cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); - - // Assert that "Play" button can be found - cy.findByRole("button", { name: "Play" }).should("exist"); - }); - }); - - it("should support downloading an audio file", () => { - uploadFile("cypress/fixtures/1sec.ogg"); - - // Find and click "Download" button on MessageActionBar - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Download" }).click(); - - // Assert that the file was downloaded - cy.readFile("cypress/downloads/1sec.ogg").should("exist"); - }); - - it("should support replying to audio file with another audio file", () => { - uploadFile("cypress/fixtures/1sec.ogg"); - - // Assert the audio player is rendered - cy.get(".mx_EventTile_last .mx_AudioPlayer_container").should("exist"); - - // Find and click "Reply" button on MessageActionBar - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply" }).click(); - - // Reply to the player with another audio file - uploadFile("cypress/fixtures/1sec.ogg"); - - cy.get(".mx_EventTile_last").within(() => { - // Assert that the audio player is rendered - cy.get(".mx_AudioPlayer_container").should("exist"); - - // Assert that replied audio file is rendered as file button inside ReplyChain - cy.get(".mx_ReplyChain_wrapper .mx_MFileBody_info[role='button']").within(() => { - // Assert that the file button has file name - cy.get(".mx_MFileBody_info_filename").should("exist"); - }); - }); - - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 - //takeSnapshots("Selected EventTile of audio player with a reply"); - }); - - it("should support creating a reply chain with multiple audio files", () => { - // Note: "mx_ReplyChain" element is used not only for replies which - // create a reply chain, but also for a single reply without a replied - // message. This test checks whether a reply chain which consists of - // multiple audio file replies is rendered properly. - - // Find and click "Reply" button - const clickButtonReply = () => { - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply" }).click(); - }; - - uploadFile("cypress/fixtures/upload-first.ogg"); - - // Assert that the audio player is rendered - cy.get(".mx_EventTile_last .mx_AudioPlayer_container").should("exist"); - - clickButtonReply(); - - // Reply to the player with another audio file - uploadFile("cypress/fixtures/upload-second.ogg"); - - // Assert that the audio player is rendered - cy.get(".mx_EventTile_last .mx_AudioPlayer_container").should("exist"); - - clickButtonReply(); - - // Reply to the player with yet another audio file to create a reply chain - uploadFile("cypress/fixtures/upload-third.ogg"); - - cy.get(".mx_EventTile_last").within(() => { - // Assert that the audio player is rendered - cy.get(".mx_AudioPlayer_container").should("exist"); - - // Assert that there are two "mx_ReplyChain" elements - cy.get(".mx_ReplyChain").should("have.length", 2); - - // Assert that one line contains the user name - cy.get(".mx_ReplyChain .mx_ReplyTile_sender").within(() => { - cy.findByText(TEST_USER); - }); - - // Assert that the other line contains the file button - cy.get(".mx_ReplyChain .mx_MFileBody").should("exist"); - - // Click "In reply to" - cy.contains(".mx_ReplyChain .mx_ReplyChain_show", "In reply to").click(); - - cy.get("blockquote.mx_ReplyChain:first-of-type").within(() => { - // Assert that "In reply to" has disappeared - cy.findByText("In reply to").should("not.exist"); - - // Assert that audio file on the first row is rendered as file button - cy.get(".mx_MFileBody_info[role='button']").within(() => { - // Assert that the file button contains the name of the file sent at first - cy.contains(".mx_MFileBody_info_filename", "upload-first.ogg"); - }); - }); - }); - - // Take snapshots - takeSnapshots("Selected EventTile of audio player with a reply chain"); - }); - - it("should be rendered, play, and support replying on a thread", () => { - uploadFile("cypress/fixtures/1sec-long-name-audio-file.ogg"); - - // On the main timeline - cy.get(".mx_RoomView_MessageList").within(() => { - // Assert the audio player is rendered - cy.get(".mx_EventTile_last .mx_AudioPlayer_container").should("exist"); - - // Find and click "Reply in thread" button - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply in thread" }).click(); - }); - - // On a thread - cy.get(".mx_ThreadView").within(() => { - cy.get(".mx_EventTile_last").within(() => { - // Assert that the player is correctly rendered on a thread - cy.get(".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container").within(() => { - // Assert that the counter is zero before clicking the play button - cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); - - // Find and click "Play" button, the wait is to make the test less flaky - cy.findByRole("button", { name: "Play" }).should("exist"); - cy.wait(500).findByRole("button", { name: "Play" }).click(); - - // Assert that "Pause" button can be found - cy.findByRole("button", { name: "Pause" }).should("exist"); - - // Assert that the timer is reset when the audio file finished playing - cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); - - // Assert that "Play" button can be found - cy.findByRole("button", { name: "Play" }).should("exist").should("not.have.attr", "disabled"); - }); - }); - - // Find and click "Reply" button - // - // Calling cy.get(".mx_EventTile_last") again here is a workaround for - // https://github.com/matrix-org/matrix-js-sdk/issues/3394: the event tile may have been re-mounted while - // the audio was playing. - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply" }).click(); - - cy.get(".mx_MessageComposer--compact").within(() => { - // Assert that the reply preview is rendered on the message composer - cy.get(".mx_ReplyPreview").within(() => { - // Assert that the reply preview contains audio ReplyTile the file info button - cy.get(".mx_ReplyTile_audio .mx_MFileBody_info[role='button']").should("exist"); - }); - - // Select :smile: emoji and send it - cy.findByTestId("basicmessagecomposer").type(":smile:"); - cy.get(".mx_Autocomplete_Completion[aria-selected='true']").click(); - cy.findByTestId("basicmessagecomposer").type("{enter}"); - }); - - cy.get(".mx_EventTile_last").within(() => { - // Assert that the file name is rendered on the file button - cy.get(".mx_ReplyTile_audio .mx_MFileBody_info[role='button']").should("exist"); - }); - }); - }); -}); diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts deleted file mode 100644 index 43b81d514d99..000000000000 --- a/cypress/e2e/composer/composer.spec.ts +++ /dev/null @@ -1,352 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// -import { EventType } from "matrix-js-sdk/src/matrix"; - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { MatrixClient } from "../../global"; - -describe("Composer", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - describe("CIDER", () => { - beforeEach(() => { - cy.initTestUser(homeserver, "Janet"); - cy.createRoom({ name: "Composing Room" }).then((roomId) => cy.viewRoomById(roomId)); - }); - - it("sends a message when you click send or press Enter", () => { - // Type a message - cy.findByRole("textbox", { name: "Send a message…" }).type("my message 0"); - // It has not been sent yet - cy.contains(".mx_EventTile_body", "my message 0").should("not.exist"); - - // Click send - cy.findByRole("button", { name: "Send message" }).click(); - // It has been sent - cy.get(".mx_EventTile_last .mx_EventTile_body").within(() => { - cy.findByText("my message 0").should("exist"); - }); - - // Type another and press Enter afterwards - cy.findByRole("textbox", { name: "Send a message…" }).type("my message 1{enter}"); - // It was sent - cy.get(".mx_EventTile_last .mx_EventTile_body").within(() => { - cy.findByText("my message 1").should("exist"); - }); - }); - - it("can write formatted text", () => { - cy.findByRole("textbox", { name: "Send a message…" }).type("my bold{ctrl+b} message"); - cy.findByRole("button", { name: "Send message" }).click(); - // Note: both "bold" and "message" are bold, which is probably surprising - cy.get(".mx_EventTile_body strong").within(() => { - cy.findByText("bold message").should("exist"); - }); - }); - - it("should allow user to input emoji via graphical picker", () => { - cy.getComposer(false).within(() => { - cy.findByRole("button", { name: "Emoji" }).click(); - }); - - cy.findByTestId("mx_EmojiPicker").within(() => { - cy.contains(".mx_EmojiPicker_item", "😇").click(); - }); - - cy.get(".mx_ContextualMenu_background").click(); // Close emoji picker - cy.findByRole("textbox", { name: "Send a message…" }).type("{enter}"); // Send message - - cy.get(".mx_EventTile_body").within(() => { - cy.findByText("😇"); - }); - }); - - describe("when Ctrl+Enter is required to send", () => { - beforeEach(() => { - cy.setSettingValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); - }); - - it("only sends when you press Ctrl+Enter", () => { - // Type a message and press Enter - cy.findByRole("textbox", { name: "Send a message…" }).type("my message 3{enter}"); - // It has not been sent yet - cy.contains(".mx_EventTile_body", "my message 3").should("not.exist"); - - // Press Ctrl+Enter - cy.findByRole("textbox", { name: "Send a message…" }).type("{ctrl+enter}"); - // It was sent - cy.get(".mx_EventTile_last .mx_EventTile_body").within(() => { - cy.findByText("my message 3").should("exist"); - }); - }); - }); - }); - - describe("Rich text editor", () => { - beforeEach(() => { - cy.enableLabsFeature("feature_wysiwyg_composer"); - cy.initTestUser(homeserver, "Janet"); - cy.createRoom({ name: "Composing Room" }).then((roomId) => cy.viewRoomById(roomId)); - }); - - describe("Commands", () => { - // TODO add tests for rich text mode - - describe("Plain text mode", () => { - it("autocomplete behaviour tests", () => { - // Select plain text mode after composer is ready - cy.get("div[contenteditable=true]").should("exist"); - cy.findByRole("button", { name: "Hide formatting" }).click(); - - // Typing a single / displays the autocomplete menu and contents - cy.findByRole("textbox").type("/"); - - // Check that the autocomplete options are visible and there are more than 0 items - cy.findByTestId("autocomplete-wrapper").should("not.be.empty"); - - // Entering `//` or `/ ` hides the autocomplete contents - // Add an extra slash for `//` - cy.findByRole("textbox").type("/"); - cy.findByTestId("autocomplete-wrapper").should("be.empty"); - // Remove the extra slash to go back to `/` - cy.findByRole("textbox").type("{Backspace}"); - cy.findByTestId("autocomplete-wrapper").should("not.be.empty"); - // Add a trailing space for `/ ` - cy.findByRole("textbox").type(" "); - cy.findByTestId("autocomplete-wrapper").should("be.empty"); - - // Typing a command that takes no arguments (/devtools) and selecting by click works - cy.findByRole("textbox").type("{Backspace}dev"); - cy.findByTestId("autocomplete-wrapper").within(() => { - cy.findByText("/devtools").click(); - }); - // Check it has closed the autocomplete and put the text into the composer - cy.findByTestId("autocomplete-wrapper").should("not.be.visible"); - cy.findByRole("textbox").within(() => { - cy.findByText("/devtools").should("exist"); - }); - // Send the message and check the devtools dialog appeared, then close it - cy.findByRole("button", { name: "Send message" }).click(); - cy.findByRole("dialog").within(() => { - cy.findByText("Developer Tools").should("exist"); - }); - cy.findByRole("button", { name: "Close dialog" }).click(); - - // Typing a command that takes arguments (/spoiler) and selecting with enter works - cy.findByRole("textbox").type("/spoil"); - cy.findByTestId("autocomplete-wrapper").within(() => { - cy.findByText("/spoiler").should("exist"); - }); - cy.findByRole("textbox").type("{Enter}"); - // Check it has closed the autocomplete and put the text into the composer - cy.findByTestId("autocomplete-wrapper").should("not.be.visible"); - cy.findByRole("textbox").within(() => { - cy.findByText("/spoiler").should("exist"); - }); - // Enter some more text, then send the message - cy.findByRole("textbox").type("this is the spoiler text "); - cy.findByRole("button", { name: "Send message" }).click(); - // Check that a spoiler item has appeared in the timeline and contains the spoiler command text - cy.get("button.mx_EventTile_spoiler").should("exist"); - cy.findByText("this is the spoiler text").should("exist"); - }); - }); - }); - - describe("Mentions", () => { - // TODO add tests for rich text mode - - describe("Plain text mode", () => { - // https://github.com/vector-im/element-web/issues/26037 - it.skip("autocomplete behaviour tests", () => { - // Set up a private room so we have another user to mention - const otherUserName = "Bob"; - let bobClient: MatrixClient; - cy.getBot(homeserver, { - displayName: otherUserName, - }).then((bob) => { - bobClient = bob; - }); - // create DM with bob - cy.getClient() - .then(async (cli) => { - const bobRoom = await cli.createRoom({ is_direct: true }); - await cli.invite(bobRoom.room_id, bobClient.getUserId()); - await cli.setAccountData("m.direct" as EventType, { - [bobClient.getUserId()]: [bobRoom.room_id], - }); - return bobRoom.room_id; - }) - .then((bobRoomId) => cy.viewRoomById(bobRoomId)); - - // Select plain text mode after composer is ready - cy.get("div[contenteditable=true]").should("exist"); - cy.findByRole("button", { name: "Hide formatting" }).click(); - - // Typing a single @ does not display the autocomplete menu and contents - cy.findByRole("textbox").type("@"); - cy.findByTestId("autocomplete-wrapper").should("be.empty"); - - // Entering the first letter of the other user's name opens the autocomplete... - cy.findByRole("textbox").type(otherUserName.slice(0, 1)); - cy.findByTestId("autocomplete-wrapper") - .should("not.be.empty") - .within(() => { - // ...with the other user name visible, and clicking that username... - cy.findByText(otherUserName).should("exist").click(); - }); - // ...inserts the username into the composer - cy.findByRole("textbox").within(() => { - cy.findByText(otherUserName, { exact: false }) - .should("exist") - .should("have.attr", "contenteditable", "false") - .should("have.attr", "data-mention-type", "user"); - }); - - // Send the message to clear the composer - cy.findByRole("button", { name: "Send message" }).click(); - - // Typing an @, then other user's name, then trailing space closes the autocomplete - cy.findByRole("textbox").type(`@${otherUserName} `); - cy.findByTestId("autocomplete-wrapper").should("be.empty"); - - // Send the message to clear the composer - cy.findByRole("button", { name: "Send message" }).click(); - - // Moving the cursor back to an "incomplete" mention opens the autocomplete - cy.findByRole("textbox").type(`initial text @${otherUserName.slice(0, 1)} abc`); - cy.findByTestId("autocomplete-wrapper").should("be.empty"); - // Move the cursor left by 4 to put it to: `@B| abc`, check autocomplete displays - cy.findByRole("textbox").type(`${"{leftArrow}".repeat(4)}`); - cy.findByTestId("autocomplete-wrapper").should("not.be.empty"); - - // Selecting the autocomplete option using Enter inserts it into the composer - cy.findByRole("textbox").type(`{Enter}`); - cy.findByRole("textbox").within(() => { - cy.findByText(otherUserName, { exact: false }) - .should("exist") - .should("have.attr", "contenteditable", "false") - .should("have.attr", "data-mention-type", "user"); - }); - }); - }); - }); - - it("sends a message when you click send or press Enter", () => { - // Type a message - cy.get("div[contenteditable=true]").type("my message 0"); - // It has not been sent yet - cy.contains(".mx_EventTile_body", "my message 0").should("not.exist"); - - // Click send - cy.findByRole("button", { name: "Send message" }).click(); - // It has been sent - cy.get(".mx_EventTile_last .mx_EventTile_body").within(() => { - cy.findByText("my message 0").should("exist"); - }); - - // Type another - cy.get("div[contenteditable=true]").type("my message 1"); - // Send message - cy.get("div[contenteditable=true]").type("{enter}"); - // It was sent - cy.get(".mx_EventTile_last .mx_EventTile_body").within(() => { - cy.findByText("my message 1").should("exist"); - }); - }); - - it("sends only one message when you press Enter multiple times", () => { - // Type a message - cy.get("div[contenteditable=true]").type("my message 0"); - // It has not been sent yet - cy.contains(".mx_EventTile_body", "my message 0").should("not.exist"); - - // Click send - cy.get("div[contenteditable=true]").type("{enter}"); - cy.get("div[contenteditable=true]").type("{enter}"); - cy.get("div[contenteditable=true]").type("{enter}"); - // It has been sent - cy.get(".mx_EventTile_last .mx_EventTile_body").within(() => { - cy.findByText("my message 0").should("exist"); - }); - cy.get(".mx_EventTile_last .mx_EventTile_body").should("have.length", 1); - }); - - it("can write formatted text", () => { - cy.get("div[contenteditable=true]").type("my {ctrl+b}bold{ctrl+b} message"); - cy.findByRole("button", { name: "Send message" }).click(); - cy.get(".mx_EventTile_body strong").within(() => { - cy.findByText("bold").should("exist"); - }); - }); - - describe("when Ctrl+Enter is required to send", () => { - beforeEach(() => { - cy.setSettingValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); - }); - - it("only sends when you press Ctrl+Enter", () => { - // Type a message and press Enter - cy.get("div[contenteditable=true]").type("my message 3"); - cy.get("div[contenteditable=true]").type("{enter}"); - // It has not been sent yet - cy.contains(".mx_EventTile_body", "my message 3").should("not.exist"); - - // Press Ctrl+Enter - cy.get("div[contenteditable=true]").type("{ctrl+enter}"); - // It was sent - cy.get(".mx_EventTile_last .mx_EventTile_body").within(() => { - cy.findByText("my message 3").should("exist"); - }); - }); - }); - - describe("links", () => { - it("create link with a forward selection", () => { - // Type a message - cy.get("div[contenteditable=true]").type("my message 0{selectAll}"); - - // Open link modal - cy.findByRole("button", { name: "Link" }).click(); - // Fill the link field - cy.findByRole("textbox", { name: "Link" }).type("https://matrix.org/"); - // Click on save - cy.findByRole("button", { name: "Save" }).click(); - // Send the message - cy.findByRole("button", { name: "Send message" }).click(); - - // It was sent - cy.get(".mx_EventTile_body a").within(() => { - cy.findByText("my message 0").should("exist"); - }); - cy.get(".mx_EventTile_body a").should("have.attr", "href").and("include", "https://matrix.org/"); - }); - }); - }); -}); diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts deleted file mode 100644 index 4680a4b08681..000000000000 --- a/cypress/e2e/crypto/crypto.spec.ts +++ /dev/null @@ -1,553 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; -import type { VerificationRequest } from "matrix-js-sdk/src/crypto-api"; -import type { CypressBot } from "../../support/bot"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; -import { - createSharedRoomWithUser, - doTwoWaySasVerification, - downloadKey, - enableKeyBackup, - logIntoElement, - logOutOfElement, - waitForVerificationRequest, -} from "./utils"; - -interface CryptoTestContext extends Mocha.Context { - homeserver: HomeserverInstance; - bob: CypressBot; -} - -const openRoomInfo = () => { - cy.findByRole("button", { name: "Room info" }).click(); - return cy.get(".mx_RightPanel"); -}; - -const checkDMRoom = () => { - cy.get(".mx_RoomView_body").within(() => { - cy.findByText("Alice created this DM.").should("exist"); - cy.findByText("Alice invited Bob", { timeout: 1000 }).should("exist"); - - cy.get(".mx_cryptoEvent").within(() => { - cy.findByText("Encryption enabled").should("exist"); - }); - }); -}; - -const startDMWithBob = function (this: CryptoTestContext) { - cy.get(".mx_RoomList").within(() => { - cy.findByRole("button", { name: "Start chat" }).click(); - }); - cy.findByTestId("invite-dialog-input").type(this.bob.getUserId()); - cy.get(".mx_InviteDialog_tile_nameStack_name").within(() => { - cy.findByText("Bob").click(); - }); - cy.get(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").within(() => { - cy.findByText("Bob").should("exist"); - }); - cy.findByRole("button", { name: "Go" }).click(); -}; - -const testMessages = function (this: CryptoTestContext) { - // check the invite message - cy.findByText("Hey!") - .closest(".mx_EventTile") - .within(() => { - cy.get(".mx_EventTile_e2eIcon_warning").should("not.exist"); - }); - - // Bob sends a response - cy.get("@bobsRoom").then((room) => { - this.bob.sendTextMessage(room.roomId, "Hoo!"); - }); - cy.findByText("Hoo!").closest(".mx_EventTile").should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); -}; - -const bobJoin = function (this: CryptoTestContext) { - cy.window({ log: false }) - .then(async (win) => { - const bobRooms = this.bob.getRooms(); - if (!bobRooms.length) { - await new Promise((resolve) => { - const onMembership = (_event) => { - this.bob.off(win.matrixcs.RoomMemberEvent.Membership, onMembership); - resolve(); - }; - this.bob.on(win.matrixcs.RoomMemberEvent.Membership, onMembership); - }); - } - }) - .then(() => { - cy.botJoinRoomByName(this.bob, "Alice").as("bobsRoom"); - }); - - cy.findByText("Bob joined the room").should("exist"); -}; - -/** configure the given MatrixClient to auto-accept any invites */ -function autoJoin(client: MatrixClient) { - cy.window({ log: false }).then(async (win) => { - client.on(win.matrixcs.RoomMemberEvent.Membership, (event, member) => { - if (member.membership === "invite" && member.userId === client.getUserId()) { - client.joinRoom(member.roomId); - } - }); - }); -} - -const verify = function (this: CryptoTestContext) { - const bobsVerificationRequestPromise = waitForVerificationRequest(this.bob); - - openRoomInfo().within(() => { - cy.findByRole("menuitem", { name: "People" }).click(); - cy.findByText("Bob").click(); - cy.findByRole("button", { name: "Verify" }).click(); - cy.findByRole("button", { name: "Start Verification" }).click(); - - // this requires creating a DM, so can take a while. Give it a longer timeout. - cy.findByRole("button", { name: "Verify by emoji", timeout: 30000 }).click(); - - cy.wrap(bobsVerificationRequestPromise).then(async (request: VerificationRequest) => { - // the bot user races with the Element user to hit the "verify by emoji" button - const verifier = await request.startVerification("m.sas.v1"); - doTwoWaySasVerification(verifier); - }); - cy.findByRole("button", { name: "They match" }).click(); - cy.findByText("You've successfully verified Bob!").should("exist"); - cy.findByRole("button", { name: "Got it" }).click(); - }); -}; - -describe("Cryptography", function () { - let aliceCredentials: UserCredentials; - let homeserver: HomeserverInstance; - let bob: CypressBot; - - beforeEach(function () { - cy.startHomeserver("default") - .as("homeserver") - .then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Alice", undefined, "alice_").then((credentials) => { - aliceCredentials = credentials; - }); - return cy.getBot(homeserver, { - displayName: "Bob", - autoAcceptInvites: false, - userIdPrefix: "bob_", - }); - }) - .as("bob") - .then((data) => { - bob = data; - }); - }); - - afterEach(function (this: CryptoTestContext) { - cy.stopHomeserver(this.homeserver); - }); - - for (const isDeviceVerified of [true, false]) { - it(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => { - /** - * Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server - * @param keyType - */ - function verifyKey(keyType: string) { - return cy - .getClient() - .then((cli) => cy.wrap(cli.getAccountDataFromServer(`m.cross_signing.${keyType}`))) - .then((accountData: { encrypted: Record> }) => { - expect(accountData.encrypted).to.exist; - const keys = Object.keys(accountData.encrypted); - const key = accountData.encrypted[keys[0]]; - expect(key.ciphertext).to.exist; - expect(key.iv).to.exist; - expect(key.mac).to.exist; - }); - } - - it("by recovery code", () => { - // Verified the device - if (isDeviceVerified) { - cy.bootstrapCrossSigning(aliceCredentials); - } - - cy.openUserSettings("Security & Privacy"); - cy.findByRole("button", { name: "Set up Secure Backup" }).click(); - cy.get(".mx_Dialog").within(() => { - // Recovery key is selected by default - cy.findByRole("button", { name: "Continue" }).click(); - cy.get(".mx_CreateSecretStorageDialog_recoveryKey code").invoke("text").as("securityKey"); - - downloadKey(); - - // When the device is verified, the `Setting up keys` step is skipped - if (!isDeviceVerified) { - cy.get(".mx_InteractiveAuthDialog").within(() => { - cy.get(".mx_Dialog_title").within(() => { - cy.findByText("Setting up keys").should("exist"); - cy.findByText("Setting up keys").should("not.exist"); - }); - }); - } - - cy.findByText("Secure Backup successful").should("exist"); - cy.findByRole("button", { name: "Done" }).click(); - cy.findByText("Secure Backup successful").should("not.exist"); - }); - - // Verify that the SSSS keys are in the account data stored in the server - verifyKey("master"); - verifyKey("self_signing"); - verifyKey("user_signing"); - }); - - it("by passphrase", () => { - // Verified the device - if (isDeviceVerified) { - cy.bootstrapCrossSigning(aliceCredentials); - } - - cy.openUserSettings("Security & Privacy"); - cy.findByRole("button", { name: "Set up Secure Backup" }).click(); - cy.get(".mx_Dialog").within(() => { - // Select passphrase option - cy.findByText("Enter a Security Phrase").click(); - cy.findByRole("button", { name: "Continue" }).click(); - - // Fill passphrase input - cy.get("input").type("new passphrase for setting up a secure key backup"); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - // Confirm passphrase - cy.get("input").type("new passphrase for setting up a secure key backup"); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - - downloadKey(); - - cy.findByText("Secure Backup successful").should("exist"); - cy.findByRole("button", { name: "Done" }).click(); - cy.findByText("Secure Backup successful").should("not.exist"); - }); - - // Verify that the SSSS keys are in the account data stored in the server - verifyKey("master"); - verifyKey("self_signing"); - verifyKey("user_signing"); - }); - }); - } - - it("creating a DM should work, being e2e-encrypted / user verification", function (this: CryptoTestContext) { - cy.bootstrapCrossSigning(aliceCredentials); - startDMWithBob.call(this); - // send first message - cy.findByRole("textbox", { name: "Send a message…" }).type("Hey!{enter}"); - checkDMRoom(); - bobJoin.call(this); - testMessages.call(this); - verify.call(this); - - // Assert that verified icon is rendered - cy.findByRole("button", { name: "Room members" }).click(); - cy.findByRole("button", { name: "Room information" }).click(); - cy.get('.mx_RoomSummaryCard_badges [data-kind="success"]').should("contain.text", "Encrypted"); - - // Take a snapshot of RoomSummaryCard with a verified E2EE icon - cy.get(".mx_RightPanel").percySnapshotElement("RoomSummaryCard - with a verified E2EE icon", { - widths: [264], // Emulate the UI. The value is based on minWidth specified on MainSplit.tsx - }); - }); - - it("should allow verification when there is no existing DM", function (this: CryptoTestContext) { - cy.bootstrapCrossSigning(aliceCredentials); - autoJoin(this.bob); - - // we need to have a room with the other user present, so we can open the verification panel - createSharedRoomWithUser(this.bob.getUserId()); - verify.call(this); - }); - - describe("event shields", () => { - let testRoomId: string; - - beforeEach(() => { - cy.bootstrapCrossSigning(aliceCredentials); - autoJoin(bob); - - // create an encrypted room - createSharedRoomWithUser(bob.getUserId()) - .as("testRoomId") - .then((roomId) => { - testRoomId = roomId; - - // enable encryption - cy.getClient().then((cli) => { - cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" }); - }); - }); - }); - - it("should show the correct shield on e2e events", function (this: CryptoTestContext) { - // Bob has a second, not cross-signed, device - let bobSecondDevice: MatrixClient; - cy.loginBot(homeserver, bob.getUserId(), bob.__cypress_password, {}).then(async (data) => { - bobSecondDevice = data; - }); - - /* Should show an error for a decryption failure */ - cy.log("Testing decryption failure"); - - cy.wrap(0) - .then(() => - bob.sendEvent(testRoomId, "m.room.encrypted", { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "the bird is in the hand", - }), - ) - .then((resp) => cy.log(`Bob sent undecryptable event ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "Unable to decrypt message") - .find(".mx_EventTile_e2eIcon") - .should("have.class", "mx_EventTile_e2eIcon_decryption_failure") - .should("have.attr", "aria-label", "This message could not be decrypted"); - - /* Should show a red padlock for an unencrypted message in an e2e room */ - cy.log("Testing unencrypted message"); - cy.wrap(0) - .then(() => - bob.http.authedRequest( - // @ts-ignore-next this wants a Method instance, but that is hard to get to here - "PUT", - `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, - undefined, - { - msgtype: "m.text", - body: "test unencrypted", - }, - ), - ) - .then((resp) => cy.log(`Bob sent unencrypted event with event id ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "test unencrypted") - .find(".mx_EventTile_e2eIcon") - .should("have.class", "mx_EventTile_e2eIcon_warning") - .should("have.attr", "aria-label", "Not encrypted"); - - /* Should show no padlock for an unverified user */ - cy.log("Testing message from unverified user"); - - // bob sends a valid event - cy.wrap(0) - .then(() => bob.sendTextMessage(testRoomId, "test encrypted 1")) - .then((resp) => cy.log(`Bob sent message from primary device with event id ${resp.event_id}`)); - - // the message should appear, decrypted, with no warning, but also no "verified" - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 1") - // no e2e icon - .should("not.have.descendants", ".mx_EventTile_e2eIcon"); - - /* Now verify Bob */ - cy.log("Verifying Bob"); - - verify.call(this); - - /* Existing message should be updated when user is verified. */ - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 1") - // still no e2e icon - .should("not.have.descendants", ".mx_EventTile_e2eIcon"); - - /* should show no padlock, and be verified, for a message from a verified device */ - cy.log("Testing message from verified device"); - cy.wrap(0) - .then(() => bob.sendTextMessage(testRoomId, "test encrypted 2")) - .then((resp) => cy.log(`Bob sent second message from primary device with event id ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 2") - // no e2e icon - .should("not.have.descendants", ".mx_EventTile_e2eIcon"); - - /* should show red padlock for a message from an unverified device */ - cy.log("Testing message from unverified device of verified user"); - cy.wrap(0) - .then(() => bobSecondDevice.sendTextMessage(testRoomId, "test encrypted from unverified")) - .then((resp) => cy.log(`Bob sent message from unverified device with event id ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted from unverified") - .find(".mx_EventTile_e2eIcon") - .should("have.class", "mx_EventTile_e2eIcon_warning") - .should("have.attr", "aria-label", "Encrypted by a device not verified by its owner."); - - /* Should show a grey padlock for a message from an unknown device */ - cy.log("Testing message from unknown device"); - - // bob deletes his second device - cy.wrap(0) - .then(() => bobSecondDevice.logout(true)) - .then(() => cy.log(`Bob logged out second device`)); - - // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. - function awaitOneDevice(iterations = 1) { - let sessionCountText: string; - cy.get(".mx_RightPanel") - .within(() => { - cy.findByRole("button", { name: "Room members" }).click(); - cy.findByText("Bob").click(); - return cy - .get(".mx_UserInfo_devices") - .findByText(" session", { exact: false }) - .then((data) => { - sessionCountText = data.text(); - }); - }) - .then(() => { - cy.log(`At ${new Date().toISOString()}: Bob has '${sessionCountText}'`); - // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here - if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { - if (iterations >= 10) { - throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); - } - awaitOneDevice(iterations + 1); - } - }); - } - - awaitOneDevice(); - - // close and reopen the room, to get the shield to update. - cy.viewRoomByName("Bob"); - cy.viewRoomByName("TestRoom"); - - // some debate over whether this should have a red or a grey shield. Legacy crypto shows a grey shield, - // Rust crypto a red one. - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted from unverified") - .find(".mx_EventTile_e2eIcon") - //.should("have.class", "mx_EventTile_e2eIcon_normal") - .should("have.attr", "aria-label", "Encrypted by an unknown or deleted device."); - }); - - it("Should show a grey padlock for a key restored from backup", () => { - enableKeyBackup(); - - // bob sends a valid event - cy.wrap(0) - .then(() => bob.sendTextMessage(testRoomId, "test encrypted 1")) - .then((resp) => cy.log(`Bob sent message from primary device with event id ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 1") - // no e2e icon - .should("not.have.descendants", ".mx_EventTile_e2eIcon"); - - // It can take up to 10 seconds for the key to be backed up. We don't really have much option other than - // to wait :/ - cy.wait(10000); - - /* log out, and back in */ - logOutOfElement(); - cy.get("@securityKey").then((securityKey) => { - logIntoElement(homeserver.baseUrl, aliceCredentials.username, aliceCredentials.password, securityKey); - }); - - /* go back to the test room and find Bob's message again */ - cy.viewRoomById(testRoomId); - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 1") - .find(".mx_EventTile_e2eIcon") - .should("have.class", "mx_EventTile_e2eIcon_normal") - .should( - "have.attr", - "aria-label", - "The authenticity of this encrypted message can't be guaranteed on this device.", - ); - }); - - it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) { - // bob has a second, not cross-signed, device - cy.loginBot(this.homeserver, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice"); - - // verify Bob - verify.call(this); - - cy.get("@testRoomId").then((roomId) => { - // bob sends a valid event - cy.wrap(this.bob.sendTextMessage(roomId, "Hoo!")).as("testEvent"); - - // the message should appear, decrypted, with no warning - cy.get(".mx_EventTile_last .mx_EventTile_body") - .within(() => { - cy.findByText("Hoo!"); - }) - .closest(".mx_EventTile") - .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); - - // bob sends an edit to the first message with his unverified device - cy.get("@bobSecondDevice").then((bobSecondDevice) => { - cy.get("@testEvent").then((testEvent) => { - bobSecondDevice.sendMessage(roomId, { - "m.new_content": { - msgtype: "m.text", - body: "Haa!", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: testEvent.event_id, - }, - }); - }); - }); - - // the edit should have a warning - cy.contains(".mx_EventTile_body", "Haa!") - .closest(".mx_EventTile") - .within(() => { - cy.get(".mx_EventTile_e2eIcon_warning").should("exist"); - }); - - // a second edit from the verified device should be ok - cy.get("@testEvent").then((testEvent) => { - this.bob.sendMessage(roomId, { - "m.new_content": { - msgtype: "m.text", - body: "Hee!", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: testEvent.event_id, - }, - }); - }); - - cy.get(".mx_EventTile_last .mx_EventTile_body") - .within(() => { - cy.findByText("Hee!"); - }) - .closest(".mx_EventTile") - .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); - }); - }); - }); -}); diff --git a/cypress/e2e/crypto/utils.ts b/cypress/e2e/crypto/utils.ts deleted file mode 100644 index d0264ec99c95..000000000000 --- a/cypress/e2e/crypto/utils.ts +++ /dev/null @@ -1,243 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; -import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; -import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api"; - -export type EmojiMapping = [emoji: string, name: string]; - -/** - * wait for the given client to receive an incoming verification request, and automatically accept it - * - * @param cli - matrix client we expect to receive a request - */ -export function waitForVerificationRequest(cli: MatrixClient): Promise { - return new Promise((resolve) => { - const onVerificationRequestEvent = async (request: VerificationRequest) => { - await request.accept(); - resolve(request); - }; - // @ts-ignore CryptoEvent is not exported to window.matrixcs; using the string value here - cli.once("crypto.verificationRequestReceived", onVerificationRequestEvent); - }); -} - -/** - * Automatically handle a SAS verification - * - * Given a verifier which has already been started, wait for the emojis to be received, blindly confirm they - * match, and return them - * - * @param verifier - verifier - * @returns A promise that resolves, with the emoji list, once we confirm the emojis - */ -export function handleSasVerification(verifier: Verifier): Promise { - return new Promise((resolve) => { - const onShowSas = (event: ISasEvent) => { - // @ts-ignore VerifierEvent is a pain to get at here as we don't have a reference to matrixcs; - // using the string value here - verifier.off("show_sas", onShowSas); - event.confirm(); - resolve(event.sas.emoji); - }; - - // @ts-ignore as above, avoiding reference to VerifierEvent - verifier.on("show_sas", onShowSas); - }); -} - -/** - * Check that the user has published cross-signing keys, and that the user's device has been cross-signed. - */ -export function checkDeviceIsCrossSigned(): void { - let userId: string; - let myDeviceId: string; - cy.window({ log: false }) - .then((win) => { - // Get the userId and deviceId of the current user - const cli = win.mxMatrixClientPeg.get(); - const accessToken = cli.getAccessToken()!; - const homeserverUrl = cli.getHomeserverUrl(); - myDeviceId = cli.getDeviceId(); - userId = cli.getUserId(); - return cy.request({ - method: "POST", - url: `${homeserverUrl}/_matrix/client/v3/keys/query`, - headers: { Authorization: `Bearer ${accessToken}` }, - body: { device_keys: { [userId]: [] } }, - }); - }) - .then((res) => { - // there should be three cross-signing keys - expect(res.body.master_keys[userId]).to.have.property("keys"); - expect(res.body.self_signing_keys[userId]).to.have.property("keys"); - expect(res.body.user_signing_keys[userId]).to.have.property("keys"); - - // and the device should be signed by the self-signing key - const selfSigningKeyId = Object.keys(res.body.self_signing_keys[userId].keys)[0]; - - expect(res.body.device_keys[userId][myDeviceId]).to.exist; - - const myDeviceSignatures = res.body.device_keys[userId][myDeviceId].signatures[userId]; - expect(myDeviceSignatures[selfSigningKeyId]).to.exist; - }); -} - -/** - * Check that the current device is connected to the key backup. - */ -export function checkDeviceIsConnectedKeyBackup() { - cy.findByRole("button", { name: "User menu" }).click(); - cy.get(".mx_UserMenu_contextMenu").within(() => { - cy.findByRole("menuitem", { name: "Security & Privacy" }).click(); - }); - cy.get(".mx_Dialog").within(() => { - cy.findByRole("button", { name: "Restore from Backup" }).should("exist"); - }); -} - -/** - * Fill in the login form in element with the given creds. - * - * If a `securityKey` is given, verifies the new device using the key. - */ -export function logIntoElement(homeserverUrl: string, username: string, password: string, securityKey?: string) { - cy.visit("/#/login"); - - // select homeserver - cy.findByRole("button", { name: "Edit" }).click(); - cy.findByRole("textbox", { name: "Other homeserver" }).type(homeserverUrl); - cy.findByRole("button", { name: "Continue" }).click(); - - // wait for the dialog to go away - cy.get(".mx_ServerPickerDialog").should("not.exist"); - - cy.findByRole("textbox", { name: "Username" }).type(username); - cy.findByPlaceholderText("Password").type(password); - cy.findByRole("button", { name: "Sign in" }).click(); - - // if a securityKey was given, verify the new device - if (securityKey !== undefined) { - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Verify with Security Key" }).click(); - }); - cy.get(".mx_Dialog").within(() => { - // Fill in the security key - cy.get('input[type="password"]').type(securityKey); - }); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - cy.findByRole("button", { name: "Done" }).click(); - } -} - -/** - * Queue up Cypress commands to log out of Element - */ -export function logOutOfElement() { - cy.findByRole("button", { name: "User menu" }).click(); - cy.get(".mx_UserMenu_contextMenu").within(() => { - cy.findByRole("menuitem", { name: "Sign out" }).click(); - }); - cy.get(".mx_Dialog .mx_QuestionDialog").within(() => { - cy.findByRole("button", { name: "Sign out" }).click(); - }); - - // Wait for the login page to load - cy.findByRole("heading", { name: "Sign in" }).click(); -} - -/** - * Given a SAS verifier for a bot client, add cypress commands to: - * - wait for the bot to receive the emojis - * - check that the bot sees the same emoji as the application - * - * @param botVerificationRequest - a verification request in a bot client - */ -export function doTwoWaySasVerification(verifier: Verifier): void { - // on the bot side, wait for the emojis, confirm they match, and return them - const emojiPromise = handleSasVerification(verifier); - - // then, check that our application shows an emoji panel with the same emojis. - cy.wrap(emojiPromise).then((emojis: EmojiMapping[]) => { - cy.get(".mx_VerificationShowSas_emojiSas_block").then((emojiBlocks) => { - emojis.forEach((emoji: EmojiMapping, index: number) => { - // 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(emojiBlocks[index].textContent.toLowerCase()).to.eq(emoji[0] + emoji[1].toLowerCase()); - }); - }); - }); -} - -/** - * Queue up cypress commands to open the security settings and enable secure key backup. - * - * Assumes that the current device has been cross-signed (which means that we skip a step where we set it up). - * - * Stores the security key in `@securityKey`. - */ -export function enableKeyBackup() { - cy.openUserSettings("Security & Privacy"); - cy.findByRole("button", { name: "Set up Secure Backup" }).click(); - cy.get(".mx_Dialog").within(() => { - // Recovery key is selected by default - cy.findByRole("button", { name: "Continue", timeout: 60000 }).click(); - - // copy the text ourselves - cy.get(".mx_CreateSecretStorageDialog_recoveryKey code").invoke("text").as("securityKey", { type: "static" }); - downloadKey(); - - cy.findByText("Secure Backup successful").should("exist"); - cy.findByRole("button", { name: "Done" }).click(); - cy.findByText("Secure Backup successful").should("not.exist"); - }); -} - -/** - * Queue up cypress commands to click on download button and continue - */ -export function downloadKey() { - // Clicking download instead of Copy because of https://github.com/cypress-io/cypress/issues/2851 - cy.findByRole("button", { name: "Download" }).click(); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); -} - -/** - * Create a shared, unencrypted room with the given user, and wait for them to join - * - * @param other - UserID of the other user - * @param opts - other options for the createRoom call - * - * @returns a cypress chainable which will yield the room ID - */ -export function createSharedRoomWithUser( - other: string, - opts: Omit = { name: "TestRoom" }, -): Cypress.Chainable { - return cy.createRoom({ ...opts, invite: [other] }).then((roomId) => { - cy.log(`Created test room ${roomId}`); - cy.viewRoomById(roomId); - - // wait for the other user to join the room, otherwise our attempt to open his user details may race - // with his join. - cy.findByText(" joined the room", { exact: false }).should("exist"); - - // Cypress complains if we return an immediate here rather than a promise. - return Promise.resolve(roomId); - }); -} diff --git a/cypress/e2e/crypto/verification.spec.ts b/cypress/e2e/crypto/verification.spec.ts deleted file mode 100644 index 31ee851532b7..000000000000 --- a/cypress/e2e/crypto/verification.spec.ts +++ /dev/null @@ -1,429 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import jsQR from "jsqr"; - -import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api"; -import { CypressBot } from "../../support/bot"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { emitPromise } from "../../support/util"; -import { - checkDeviceIsConnectedKeyBackup, - checkDeviceIsCrossSigned, - doTwoWaySasVerification, - logIntoElement, - waitForVerificationRequest, -} from "./utils"; -import { getToast } from "../../support/toasts"; -import { UserCredentials } from "../../support/login"; - -/** Render a data URL and return the rendered image data */ -async function renderQRCode(dataUrl: string): Promise { - // create a new image and set the source to the data url - const img = new Image(); - await new Promise((r) => { - img.onload = r; - img.src = dataUrl; - }); - - // draw the image on a canvas - const myCanvas = new OffscreenCanvas(256, 256); - const ctx = myCanvas.getContext("2d"); - ctx.drawImage(img, 0, 0); - - // read the image data - return ctx.getImageData(0, 0, myCanvas.width, myCanvas.height); -} - -describe("Device verification", () => { - let aliceBotClient: CypressBot; - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data: HomeserverInstance) => { - homeserver = data; - - // Visit the login page of the app, to load the matrix sdk - cy.visit("/#/login"); - - // wait for the page to load - cy.window({ log: false }).should("have.property", "matrixcs"); - - // Create a new device for alice - cy.getBot(homeserver, { - rustCrypto: true, - bootstrapCrossSigning: true, - bootstrapSecretStorage: true, - }).then((bot) => { - aliceBotClient = bot; - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - /* Click the "Verify with another device" button, and have the bot client auto-accept it. - * - * Stores the incoming `VerificationRequest` on the bot client as `@verificationRequest`. - */ - function initiateAliceVerificationRequest() { - // alice bot waits for verification request - const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient); - - // Click on "Verify with another device" - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Verify with another device" }).click(); - }); - - // alice bot responds yes to verification request from alice - cy.wrap(promiseVerificationRequest).as("verificationRequest"); - } - - it("Verify device with SAS during login", () => { - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - // Launch the verification request between alice and the bot - initiateAliceVerificationRequest(); - - // Handle emoji SAS verification - cy.get(".mx_InfoDialog").within(() => { - cy.get("@verificationRequest").then(async (request: VerificationRequest) => { - // the bot chooses to do an emoji verification - const verifier = await request.startVerification("m.sas.v1"); - - // Handle emoji request and check that emojis are matching - doTwoWaySasVerification(verifier); - }); - - cy.findByRole("button", { name: "They match" }).click(); - cy.findByRole("button", { name: "Got it" }).click(); - }); - - // Check that our device is now cross-signed - checkDeviceIsCrossSigned(); - - // Check that the current device is connected to key backup - checkDeviceIsConnectedKeyBackup(); - }); - - it("Verify device with QR code during login", () => { - // A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key" - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - // Launch the verification request between alice and the bot - initiateAliceVerificationRequest(); - - cy.get(".mx_InfoDialog").within(() => { - cy.get('[alt="QR Code"]').then((qrCode) => { - /* the bot scans the QR code */ - cy.get("@verificationRequest") - .then(async (request: VerificationRequest) => { - // feed the QR code into the verification request. - const qrData = await readQrCode(qrCode); - return await request.scanQRCode(qrData); - }) - .as("verifier"); - }); - - // Confirm that the bot user scanned successfully - cy.findByText("Almost there! Is your other device showing the same shield?"); - cy.findByRole("button", { name: "Yes" }).click(); - - cy.findByRole("button", { name: "Got it" }).click(); - }); - - // wait for the bot to see we have finished - cy.get("@verifier").then(async (verifier) => { - await verifier.verify(); - }); - - // the bot uploads the signatures asynchronously, so wait for that to happen - cy.wait(1000); - - // our device should trust the bot device - cy.getClient().then(async (cli) => { - const deviceStatus = await cli - .getCrypto()! - .getDeviceVerificationStatus(aliceBotClient.getUserId(), aliceBotClient.getDeviceId()); - if (!deviceStatus.isVerified()) { - throw new Error("Bot device was not verified after QR code verification"); - } - }); - - // Check that our device is now cross-signed - checkDeviceIsCrossSigned(); - - // Check that the current device is connected to key backup - checkDeviceIsConnectedKeyBackup(); - }); - - it("Verify device with Security Phrase during login", () => { - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - // Select the security phrase - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Verify with Security Key or Phrase" }).click(); - }); - - // Fill the passphrase - cy.get(".mx_Dialog").within(() => { - cy.get("input").type("new passphrase"); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - }); - - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Done" }).click(); - }); - - // Check that our device is now cross-signed - checkDeviceIsCrossSigned(); - - // Check that the current device is connected to key backup - checkDeviceIsConnectedKeyBackup(); - }); - - it("Verify device with Security Key during login", () => { - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - // Select the security phrase - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Verify with Security Key or Phrase" }).click(); - }); - - // Fill the security key - cy.get(".mx_Dialog").within(() => { - cy.findByRole("button", { name: "use your Security Key" }).click(); - cy.get("#mx_securityKey").type(aliceBotClient.__cypress_recovery_key.encodedPrivateKey); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - }); - - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Done" }).click(); - }); - - // Check that our device is now cross-signed - checkDeviceIsCrossSigned(); - - // Check that the current device is connected to key backup - checkDeviceIsConnectedKeyBackup(); - }); - - it("Handle incoming verification request with SAS", () => { - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - /* Dismiss "Verify this device" */ - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Skip verification for now" }).click(); - cy.findByRole("button", { name: "I'll verify later" }).click(); - }); - - /* figure out the device id of the Element client */ - let elementDeviceId: string; - cy.window({ log: false }).then((win) => { - const cli = win.mxMatrixClientPeg.safeGet(); - elementDeviceId = cli.getDeviceId(); - expect(elementDeviceId).to.exist; - cy.log(`Got element device id: ${elementDeviceId}`); - }); - - /* Now initiate a verification request from the *bot* device. */ - let botVerificationRequest: VerificationRequest; - cy.then(() => { - async function initVerification() { - botVerificationRequest = await aliceBotClient - .getCrypto()! - .requestDeviceVerification(aliceBotClient.getUserId(), elementDeviceId); - } - - cy.wrap(initVerification(), { log: false }); - }).then(() => { - cy.log("Initiated verification request"); - }); - - /* Check the toast for the incoming request */ - getToast("Verification requested").within(() => { - // it should contain the device ID of the requesting device - cy.contains(`${aliceBotClient.getDeviceId()} from `); - - // Accept - cy.findByRole("button", { name: "Verify Session" }).click(); - }); - - /* Click 'Start' to start SAS verification */ - cy.findByRole("button", { name: "Start" }).click(); - - /* on the bot side, wait for the verifier to exist ... */ - cy.then(() => cy.wrap(awaitVerifier(botVerificationRequest))).then((verifier: Verifier) => { - // ... confirm ... - botVerificationRequest.verifier.verify(); - - // ... and then check the emoji match - doTwoWaySasVerification(verifier); - }); - - /* And we're all done! */ - cy.get(".mx_InfoDialog").within(() => { - cy.findByRole("button", { name: "They match" }).click(); - cy.findByText(`You've successfully verified (${aliceBotClient.getDeviceId()})!`).should("exist"); - cy.findByRole("button", { name: "Got it" }).click(); - }); - }); -}); - -describe("User verification", () => { - // note that there are other tests that check user verification works in `crypto.spec.ts`. - - let aliceCredentials: UserCredentials; - let homeserver: HomeserverInstance; - let bob: CypressBot; - - beforeEach(() => { - cy.startHomeserver("default") - .as("homeserver") - .then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Alice", undefined, "alice_").then((credentials) => { - aliceCredentials = credentials; - }); - return cy.getBot(homeserver, { - displayName: "Bob", - autoAcceptInvites: true, - userIdPrefix: "bob_", - }); - }) - .then((data) => { - bob = data; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("can receive a verification request when there is no existing DM", () => { - cy.bootstrapCrossSigning(aliceCredentials); - - // the other user creates a DM - let dmRoomId: string; - let bobVerificationRequest: VerificationRequest; - cy.wrap(0).then(async () => { - dmRoomId = await createDMRoom(bob, aliceCredentials.userId); - }); - - // accept the DM - cy.viewRoomByName("Bob"); - cy.findByRole("button", { name: "Start chatting" }).click(); - - // once Alice has joined, Bob starts the verification - cy.wrap(0).then(async () => { - const room = bob.getRoom(dmRoomId)!; - while (room.getMember(aliceCredentials.userId)?.membership !== "join") { - await new Promise((resolve) => { - // @ts-ignore can't access the enum here - room.once("RoomState.members", resolve); - }); - } - bobVerificationRequest = await bob.getCrypto()!.requestVerificationDM(aliceCredentials.userId, dmRoomId); - }); - - // there should also be a toast - getToast("Verification requested").within(() => { - // it should contain the details of the requesting user - cy.contains(`Bob (${bob.credentials.userId})`); - - // Accept - cy.findByRole("button", { name: "Verify Session" }).click(); - }); - - // request verification by emoji - cy.get("#mx_RightPanel").findByRole("button", { name: "Verify by emoji" }).click(); - - cy.wrap(0) - .then(async () => { - /* on the bot side, wait for the verifier to exist ... */ - const verifier = await awaitVerifier(bobVerificationRequest); - // ... confirm ... - verifier.verify(); - return verifier; - }) - .then((botVerifier) => { - // ... and then check the emoji match - doTwoWaySasVerification(botVerifier); - }); - - cy.findByRole("button", { name: "They match" }).click(); - cy.findByText("You've successfully verified Bob!").should("exist"); - cy.findByRole("button", { name: "Got it" }).click(); - }); -}); - -/** Extract the qrcode out of an on-screen html element */ -async function readQrCode(qrCode: JQuery) { - // because I don't know how to scrape the imagedata from the cypress browser window, - // we extract the data url and render it to a new canvas. - const imageData = await renderQRCode(qrCode.attr("src")); - - // now we can decode the QR code. - const result = jsQR(imageData.data, imageData.width, imageData.height); - return new Uint8Array(result.binaryData); -} - -async function createDMRoom(client: MatrixClient, userId: string): Promise { - const r = await client.createRoom({ - // @ts-ignore can't access the enum here - preset: "trusted_private_chat", - // @ts-ignore can't access the enum here - visibility: "private", - invite: [userId], - is_direct: true, - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }); - - const roomId = r.room_id; - - // wait for the room to come down /sync - while (!client.getRoom(roomId)) { - await new Promise((resolve) => { - //@ts-ignore can't access the enum here - client.once("Room", resolve); - }); - } - - return roomId; -} - -/** - * Wait for a verifier to exist for a VerificationRequest - * - * @param botVerificationRequest - */ -async function awaitVerifier(botVerificationRequest: VerificationRequest): Promise { - while (!botVerificationRequest.verifier) { - await emitPromise(botVerificationRequest, "change"); - } - return botVerificationRequest.verifier; -} diff --git a/cypress/e2e/integration-manager/get-openid-token.spec.ts b/cypress/e2e/integration-manager/get-openid-token.spec.ts deleted file mode 100644 index b2dcb9146ae7..000000000000 --- a/cypress/e2e/integration-manager/get-openid-token.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; - -const ROOM_NAME = "Integration Manager Test"; -const USER_DISPLAY_NAME = "Alice"; - -const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; -const INTEGRATION_MANAGER_HTML = ` - - - Fake Integration Manager - - - - -

No response

- - - -`; - -function openIntegrationManager() { - cy.findByRole("button", { name: "Room info" }).click(); - cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); -} - -function sendActionFromIntegrationManager(integrationManagerUrl: string) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.findByRole("button", { name: "Press to send action" }).should("exist").click(); - }); -} - -describe("Integration Manager: Get OpenID Token", () => { - let testUser: UserCredentials; - let homeserver: HomeserverInstance; - let integrationManagerUrl: string; - - beforeEach(() => { - cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { - integrationManagerUrl = url; - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { - cy.window().then((win) => { - win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); - win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); - }); - }).then((user) => { - testUser = user; - }); - - cy.setAccountData("m.widgets", { - "m.integration_manager": { - content: { - type: "m.integration_manager", - name: "Integration Manager", - url: integrationManagerUrl, - data: { - api_url: integrationManagerUrl, - }, - }, - id: "integration-manager", - }, - }).as("integrationManager"); - - // Succeed when checking the token is valid - cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { - req.continue((res) => { - return res.send(200, { - user_id: testUser.userId, - }); - }); - }); - - cy.createRoom({ - name: ROOM_NAME, - }).as("roomId"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should successfully obtain an openID token", () => { - cy.all([cy.get<{}>("@integrationManager")]).then(() => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl); - - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").within(() => { - cy.findByText(/access_token/); - }); - }); - }); - }); -}); diff --git a/cypress/e2e/integration-manager/kick.spec.ts b/cypress/e2e/integration-manager/kick.spec.ts deleted file mode 100644 index 7075c1c199ff..000000000000 --- a/cypress/e2e/integration-manager/kick.spec.ts +++ /dev/null @@ -1,265 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { MatrixClient } from "../../global"; -import { UserCredentials } from "../../support/login"; - -const ROOM_NAME = "Integration Manager Test"; -const USER_DISPLAY_NAME = "Alice"; -const BOT_DISPLAY_NAME = "Bob"; -const KICK_REASON = "Goodbye"; - -const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; -const INTEGRATION_MANAGER_HTML = ` - - - Fake Integration Manager - - - - - - - - - -`; - -function openIntegrationManager() { - cy.findByRole("button", { name: "Room info" }).click(); - cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); -} - -function closeIntegrationManager(integrationManagerUrl: string) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.findByRole("button", { name: "Press to close" }).should("exist").click(); - }); -} - -function sendActionFromIntegrationManager(integrationManagerUrl: string, targetRoomId: string, targetUserId: string) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#target-room-id").should("exist").type(targetRoomId); - cy.get("#target-user-id").should("exist").type(targetUserId); - cy.findByRole("button", { name: "Press to send action" }).should("exist").click(); - }); -} - -function clickUntilGone(selector: string, attempt = 0) { - if (attempt === 11) { - throw new Error("clickUntilGone attempt count exceeded"); - } - - cy.get(selector) - .last() - .click() - .then(($button) => { - const exists = Cypress.$(selector).length > 0; - if (exists) { - clickUntilGone(selector, ++attempt); - } - }); -} - -function expectKickedMessage(shouldExist: boolean) { - // Expand any event summaries, we can't use a click multiple here because clicking one might de-render others - // This is quite horrible but seems the most stable way of clicking 0-N buttons, - // one at a time with a full re-evaluation after each click - clickUntilGone(".mx_GenericEventListSummary_toggle[aria-expanded=false]"); - - // Check for the event message (or lack thereof) - cy.findByText(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`).should( - shouldExist ? "exist" : "not.exist", - ); -} - -describe("Integration Manager: Kick", () => { - let testUser: UserCredentials; - let homeserver: HomeserverInstance; - let integrationManagerUrl: string; - - beforeEach(() => { - cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { - integrationManagerUrl = url; - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { - cy.window().then((win) => { - win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); - win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); - }); - }).then((user) => { - testUser = user; - }); - - cy.setAccountData("m.widgets", { - "m.integration_manager": { - content: { - type: "m.integration_manager", - name: "Integration Manager", - url: integrationManagerUrl, - data: { - api_url: integrationManagerUrl, - }, - }, - id: "integration-manager", - }, - }).as("integrationManager"); - - // Succeed when checking the token is valid - cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { - req.continue((res) => { - return res.send(200, { - user_id: testUser.userId, - }); - }); - }); - - cy.createRoom({ - name: ROOM_NAME, - }).as("roomId"); - - cy.getBot(homeserver, { displayName: BOT_DISPLAY_NAME, autoAcceptInvites: true }).as("bob"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should kick the target", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - cy.inviteUser(roomId, targetUserId); - cy.findByText(`${BOT_DISPLAY_NAME} joined the room`).should("exist"); - - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(true); - }, - ); - }); - - it("should not kick the target if lacking permissions", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - cy.inviteUser(roomId, targetUserId); - cy.findByText(`${BOT_DISPLAY_NAME} joined the room`).should("exist"); - cy.getClient() - .then(async (client) => { - await client.sendStateEvent(roomId, "m.room.power_levels", { - kick: 50, - users: { - [testUser.userId]: 0, - }, - }); - }) - .then(() => { - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(false); - }); - }, - ); - }); - - it("should no-op if the target already left", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - cy.inviteUser(roomId, targetUserId); - cy.findByText(`${BOT_DISPLAY_NAME} joined the room`) - .should("exist") - .then(async () => { - await targetUser.leave(roomId); - }) - .then(() => { - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(false); - }); - }, - ); - }); - - it("should no-op if the target was banned", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - cy.inviteUser(roomId, targetUserId); - cy.findByText(`${BOT_DISPLAY_NAME} joined the room`).should("exist"); - cy.getClient() - .then(async (client) => { - await client.ban(roomId, targetUserId); - }) - .then(() => { - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(false); - }); - }, - ); - }); - - it("should no-op if the target was never a room member", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(false); - }, - ); - }); -}); diff --git a/cypress/e2e/integration-manager/read_events.spec.ts b/cypress/e2e/integration-manager/read_events.spec.ts deleted file mode 100644 index 65b195a3c729..000000000000 --- a/cypress/e2e/integration-manager/read_events.spec.ts +++ /dev/null @@ -1,276 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; - -const ROOM_NAME = "Integration Manager Test"; -const USER_DISPLAY_NAME = "Alice"; - -const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; -const INTEGRATION_MANAGER_HTML = ` - - - Fake Integration Manager - - - - - - - -

No response

- - - -`; - -function openIntegrationManager() { - cy.findByRole("button", { name: "Room info" }).click(); - cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { - cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); - }); -} - -function sendActionFromIntegrationManager( - integrationManagerUrl: string, - targetRoomId: string, - eventType: string, - stateKey: string | boolean, -) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#target-room-id").should("exist").type(targetRoomId); - cy.get("#event-type").should("exist").type(eventType); - cy.get("#state-key").should("exist").type(JSON.stringify(stateKey)); - cy.get("#send-action").should("exist").click(); - }); -} - -describe("Integration Manager: Read Events", () => { - let testUser: UserCredentials; - let homeserver: HomeserverInstance; - let integrationManagerUrl: string; - - beforeEach(() => { - cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { - integrationManagerUrl = url; - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { - cy.window().then((win) => { - win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); - win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); - }); - }).then((user) => { - testUser = user; - }); - - cy.setAccountData("m.widgets", { - "m.integration_manager": { - content: { - type: "m.integration_manager", - name: "Integration Manager", - url: integrationManagerUrl, - data: { - api_url: integrationManagerUrl, - }, - }, - id: "integration-manager", - }, - }).as("integrationManager"); - - // Succeed when checking the token is valid - cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { - req.continue((res) => { - return res.send(200, { - user_id: testUser.userId, - }); - }); - }); - - cy.createRoom({ - name: ROOM_NAME, - }).as("roomId"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should read a state event by state key", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - const eventType = "io.element.integrations.installations"; - const eventContent = { - foo: "bar", - }; - const stateKey = "state-key-123"; - - // Send a state event - cy.getClient() - .then(async (client) => { - return await client.sendStateEvent(roomId, eventType, eventContent, stateKey); - }) - .then((event) => { - openIntegrationManager(); - - // Read state events - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response") - .should("include.text", event.event_id) - .should("include.text", `"content":${JSON.stringify(eventContent)}`); - }); - }); - }); - }); - - it("should read a state event with empty state key", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - const eventType = "io.element.integrations.installations"; - const eventContent = { - foo: "bar", - }; - const stateKey = ""; - - // Send a state event - cy.getClient() - .then(async (client) => { - return await client.sendStateEvent(roomId, eventType, eventContent, stateKey); - }) - .then((event) => { - openIntegrationManager(); - - // Read state events - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response") - .should("include.text", event.event_id) - .should("include.text", `"content":${JSON.stringify(eventContent)}`); - }); - }); - }); - }); - - it("should read state events with any state key", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - const eventType = "io.element.integrations.installations"; - - const stateKey1 = "state-key-123"; - const eventContent1 = { - foo1: "bar1", - }; - const stateKey2 = "state-key-456"; - const eventContent2 = { - foo2: "bar2", - }; - const stateKey3 = "state-key-789"; - const eventContent3 = { - foo3: "bar3", - }; - - // Send state events - cy.getClient() - .then(async (client) => { - return Promise.all([ - client.sendStateEvent(roomId, eventType, eventContent1, stateKey1), - client.sendStateEvent(roomId, eventType, eventContent2, stateKey2), - client.sendStateEvent(roomId, eventType, eventContent3, stateKey3), - ]); - }) - .then((events) => { - openIntegrationManager(); - - // Read state events - sendActionFromIntegrationManager( - integrationManagerUrl, - roomId, - eventType, - true, // Any state key - ); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response") - .should("include.text", events[0].event_id) - .should("include.text", `"content":${JSON.stringify(eventContent1)}`) - .should("include.text", events[1].event_id) - .should("include.text", `"content":${JSON.stringify(eventContent2)}`) - .should("include.text", events[2].event_id) - .should("include.text", `"content":${JSON.stringify(eventContent3)}`); - }); - }); - }); - }); - - it("should fail to read an event type which is not allowed", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - const eventType = "com.example.event"; - const stateKey = ""; - - openIntegrationManager(); - - // Read state events - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "Failed to read events"); - }); - }); - }); -}); diff --git a/cypress/e2e/integration-manager/send_event.spec.ts b/cypress/e2e/integration-manager/send_event.spec.ts deleted file mode 100644 index d8a746b42378..000000000000 --- a/cypress/e2e/integration-manager/send_event.spec.ts +++ /dev/null @@ -1,261 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; - -const ROOM_NAME = "Integration Manager Test"; -const USER_DISPLAY_NAME = "Alice"; - -const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; -const INTEGRATION_MANAGER_HTML = ` - - - Fake Integration Manager - - - - - - - - -

No response

- - - -`; - -function openIntegrationManager() { - cy.findByRole("button", { name: "Room info" }).click(); - cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { - cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); - }); -} - -function sendActionFromIntegrationManager( - integrationManagerUrl: string, - targetRoomId: string, - eventType: string, - stateKey: string, - content: Record, -) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#target-room-id").should("exist").type(targetRoomId); - cy.get("#event-type").should("exist").type(eventType); - if (stateKey) { - cy.get("#state-key").should("exist").type(stateKey); - } - cy.get("#event-content").should("exist").type(JSON.stringify(content), { parseSpecialCharSequences: false }); - cy.get("#send-action").should("exist").click(); - }); -} - -describe("Integration Manager: Send Event", () => { - let testUser: UserCredentials; - let homeserver: HomeserverInstance; - let integrationManagerUrl: string; - - beforeEach(() => { - cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { - integrationManagerUrl = url; - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { - cy.window().then((win) => { - win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); - win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); - }); - }).then((user) => { - testUser = user; - }); - - cy.setAccountData("m.widgets", { - "m.integration_manager": { - content: { - type: "m.integration_manager", - name: "Integration Manager", - url: integrationManagerUrl, - data: { - api_url: integrationManagerUrl, - }, - }, - id: "integration-manager", - }, - }).as("integrationManager"); - - // Succeed when checking the token is valid - cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { - req.continue((res) => { - return res.send(200, { - user_id: testUser.userId, - }); - }); - }); - - cy.createRoom({ - name: ROOM_NAME, - }).as("roomId"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should send a state event", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - - const eventType = "io.element.integrations.installations"; - const eventContent = { - foo: "bar", - }; - const stateKey = "state-key-123"; - - // Send the event - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "event_id"); - }); - - // Check the event - cy.getClient() - .then(async (client) => { - return await client.getStateEvent(roomId, eventType, stateKey); - }) - .then((event) => { - expect(event).to.deep.equal(eventContent); - }); - }); - }); - - it("should send a state event with empty content", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - - const eventType = "io.element.integrations.installations"; - const eventContent = {}; - const stateKey = "state-key-123"; - - // Send the event - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "event_id"); - }); - - // Check the event - cy.getClient() - .then(async (client) => { - return await client.getStateEvent(roomId, eventType, stateKey); - }) - .then((event) => { - expect(event).to.be.empty; - }); - }); - }); - - it("should send a state event with empty state key", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - - const eventType = "io.element.integrations.installations"; - const eventContent = { - foo: "bar", - }; - const stateKey = ""; - - // Send the event - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "event_id"); - }); - - // Check the event - cy.getClient() - .then(async (client) => { - return await client.getStateEvent(roomId, eventType, stateKey); - }) - .then((event) => { - expect(event).to.deep.equal(eventContent); - }); - }); - }); - - it("should fail to send an event type which is not allowed", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - - const eventType = "com.example.event"; - const eventContent = { - foo: "bar", - }; - const stateKey = ""; - - // Send the event - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "Failed to send event"); - }); - }); - }); -}); diff --git a/cypress/e2e/knock/create-knock-room.spec.ts b/cypress/e2e/knock/create-knock-room.spec.ts deleted file mode 100644 index dbbcf49492d7..000000000000 --- a/cypress/e2e/knock/create-knock-room.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* -Copyright 2022-2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { waitForRoom } from "../utils"; -import { Filter } from "../../support/settings"; - -describe("Create Knock Room", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.enableLabsFeature("feature_ask_to_join"); - - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Alice"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should create a knock room", () => { - cy.openCreateRoomDialog().within(() => { - cy.findByRole("textbox", { name: "Name" }).type("Cybersecurity"); - cy.findByRole("button", { name: "Room visibility" }).click(); - cy.findByRole("option", { name: "Ask to join" }).click(); - - cy.findByRole("button", { name: "Create room" }).click(); - }); - - cy.get(".mx_LegacyRoomHeader").within(() => { - cy.findByText("Cybersecurity"); - }); - - cy.hash().then((urlHash) => { - const roomId = urlHash.replace("#/room/", ""); - - // Room should have a knock join rule - cy.window().then(async (win) => { - await waitForRoom(win, win.mxMatrixClientPeg.get(), roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => e.getType() === "m.room.join_rules" && e.getContent().join_rule === "knock", - ); - }); - }); - }); - }); - - it("should create a room and change a join rule to knock", () => { - cy.openCreateRoomDialog().within(() => { - cy.findByRole("textbox", { name: "Name" }).type("Cybersecurity"); - - cy.findByRole("button", { name: "Create room" }).click(); - }); - - cy.get(".mx_LegacyRoomHeader").within(() => { - cy.findByText("Cybersecurity"); - }); - - cy.hash().then((urlHash) => { - const roomId = urlHash.replace("#/room/", ""); - - cy.openRoomSettings("Security & Privacy"); - - cy.findByRole("group", { name: "Access" }).within(() => { - cy.findByRole("radio", { name: "Private (invite only)" }).should("be.checked"); - cy.findByRole("radio", { name: "Ask to join" }).check({ force: true }); - }); - - // Room should have a knock join rule - cy.window().then(async (win) => { - await waitForRoom(win, win.mxMatrixClientPeg.get(), roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => e.getType() === "m.room.join_rules" && e.getContent().join_rule === "knock", - ); - }); - }); - }); - }); - - it("should create a public knock room", () => { - cy.openCreateRoomDialog().within(() => { - cy.findByRole("textbox", { name: "Name" }).type("Cybersecurity"); - cy.findByRole("button", { name: "Room visibility" }).click(); - cy.findByRole("option", { name: "Ask to join" }).click(); - cy.findByRole("checkbox", { name: "Make this room visible in the public room directory." }).click({ - force: true, - }); - - cy.findByRole("button", { name: "Create room" }).click(); - }); - - cy.get(".mx_LegacyRoomHeader").within(() => { - cy.findByText("Cybersecurity"); - }); - - cy.hash().then((urlHash) => { - const roomId = urlHash.replace("#/room/", ""); - - // Room should have a knock join rule - cy.window().then(async (win) => { - await waitForRoom(win, win.mxMatrixClientPeg.get(), roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => e.getType() === "m.room.join_rules" && e.getContent().join_rule === "knock", - ); - }); - }); - }); - - cy.openSpotlightDialog().within(() => { - cy.spotlightFilter(Filter.PublicRooms); - cy.spotlightResults().eq(0).should("contain", "Cybersecurity"); - }); - }); -}); diff --git a/cypress/e2e/knock/knock-into-room.spec.ts b/cypress/e2e/knock/knock-into-room.spec.ts deleted file mode 100644 index 4d6a0eebe717..000000000000 --- a/cypress/e2e/knock/knock-into-room.spec.ts +++ /dev/null @@ -1,318 +0,0 @@ -/* -Copyright 2023 Mikhail Aheichyk -Copyright 2023 Nordeck IT + Consulting GmbH. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; -import { waitForRoom } from "../utils"; -import { Filter } from "../../support/settings"; - -describe("Knock Into Room", () => { - let homeserver: HomeserverInstance; - let user: UserCredentials; - let bot: MatrixClient; - - let roomId; - - beforeEach(() => { - cy.enableLabsFeature("feature_ask_to_join"); - - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Alice").then((_user) => { - user = _user; - }); - - cy.getBot(homeserver, { displayName: "Bob" }).then(async (_bot) => { - bot = _bot; - - const { room_id: newRoomId } = await bot.createRoom({ - name: "Cybersecurity", - initial_state: [ - { - type: "m.room.join_rules", - content: { - join_rule: "knock", - }, - state_key: "", - }, - ], - }); - - roomId = newRoomId; - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should knock into the room then knock is approved and user joins the room then user is kicked and joins again", () => { - cy.viewRoomById(roomId); - - cy.get(".mx_RoomPreviewBar").within(() => { - cy.findByRole("button", { name: "Join the discussion" }).click(); - - cy.findByRole("heading", { name: "Ask to join?" }); - cy.findByRole("textbox"); - cy.findByRole("button", { name: "Request access" }).click(); - - cy.findByRole("heading", { name: "Request to join sent" }); - }); - - // Knocked room should appear in Rooms - cy.findByRole("group", { name: "Rooms" }).findByRole("treeitem", { name: "Cybersecurity" }); - - cy.window().then(async (win) => { - // bot waits for knock request from Alice - await waitForRoom(win, bot, roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "m.room.member" && - e.getContent()?.membership === "knock" && - e.getContent()?.displayname === "Alice", - ); - }); - - // bot invites Alice - await bot.invite(roomId, user.userId); - }); - - cy.findByRole("group", { name: "Invites" }).findByRole("treeitem", { name: "Cybersecurity" }); - - // Alice have to accept invitation in order to join the room. - // It will be not needed when homeserver implements auto accept knock requests. - cy.get(".mx_RoomView").findByRole("button", { name: "Accept" }).click(); - - cy.findByRole("group", { name: "Rooms" }).findByRole("treeitem", { name: "Cybersecurity" }); - - cy.findByText("Alice joined the room").should("exist"); - - cy.window().then(async (win) => { - // bot kicks Alice - await bot.kick(roomId, user.userId); - }); - - cy.get(".mx_RoomPreviewBar").within(() => { - cy.findByRole("button", { name: "Re-join" }).click(); - - cy.findByRole("heading", { name: "Ask to join Cybersecurity?" }); - cy.findByRole("button", { name: "Request access" }).click(); - }); - - cy.window().then(async (win) => { - // bot waits for knock request from Alice - await waitForRoom(win, bot, roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "m.room.member" && - e.getContent()?.membership === "knock" && - e.getContent()?.displayname === "Alice", - ); - }); - - // bot invites Alice - await bot.invite(roomId, user.userId); - }); - - // Alice have to accept invitation in order to join the room. - // It will be not needed when homeserver implements auto accept knock requests. - cy.get(".mx_RoomView").findByRole("button", { name: "Accept" }).click(); - - cy.findByText("Alice was invited, joined, was removed, was invited, and joined").should("exist"); - }); - - it("should knock into the room then knock is approved and user joins the room then user is banned/unbanned and joins again", () => { - cy.viewRoomById(roomId); - - cy.get(".mx_RoomPreviewBar").within(() => { - cy.findByRole("button", { name: "Join the discussion" }).click(); - - cy.findByRole("heading", { name: "Ask to join?" }); - cy.findByRole("textbox"); - cy.findByRole("button", { name: "Request access" }).click(); - - cy.findByRole("heading", { name: "Request to join sent" }); - }); - - // Knocked room should appear in Rooms - cy.findByRole("group", { name: "Rooms" }).findByRole("treeitem", { name: "Cybersecurity" }); - - cy.window().then(async (win) => { - // bot waits for knock request from Alice - await waitForRoom(win, bot, roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "m.room.member" && - e.getContent()?.membership === "knock" && - e.getContent()?.displayname === "Alice", - ); - }); - - // bot invites Alice - await bot.invite(roomId, user.userId); - }); - - cy.findByRole("group", { name: "Invites" }).findByRole("treeitem", { name: "Cybersecurity" }); - - // Alice have to accept invitation in order to join the room. - // It will be not needed when homeserver implements auto accept knock requests. - cy.get(".mx_RoomView").findByRole("button", { name: "Accept" }).click(); - - cy.findByRole("group", { name: "Rooms" }).findByRole("treeitem", { name: "Cybersecurity" }); - - cy.findByText("Alice joined the room").should("exist"); - - cy.window().then(async (win) => { - // bot bans Alice - await bot.ban(roomId, user.userId); - }); - - cy.get(".mx_RoomPreviewBar").findByText("You were banned from Cybersecurity by Bob").should("exist"); - - cy.window().then(async (win) => { - // bot unbans Alice - await bot.unban(roomId, user.userId); - }); - - cy.get(".mx_RoomPreviewBar").within(() => { - cy.findByRole("button", { name: "Re-join" }).click(); - - cy.findByRole("heading", { name: "Ask to join Cybersecurity?" }); - cy.findByRole("button", { name: "Request access" }).click(); - }); - - cy.window().then(async (win) => { - // bot waits for knock request from Alice - await waitForRoom(win, bot, roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "m.room.member" && - e.getContent()?.membership === "knock" && - e.getContent()?.displayname === "Alice", - ); - }); - - // bot invites Alice - await bot.invite(roomId, user.userId); - }); - - // Alice have to accept invitation in order to join the room. - // It will be not needed when homeserver implements auto accept knock requests. - cy.get(".mx_RoomView").findByRole("button", { name: "Accept" }).click(); - - cy.findByText("Alice was invited, joined, was banned, was unbanned, was invited, and joined").should("exist"); - }); - - it("should knock into the room and knock is cancelled by user himself", () => { - cy.viewRoomById(roomId); - - cy.get(".mx_RoomPreviewBar").within(() => { - cy.findByRole("button", { name: "Join the discussion" }).click(); - - cy.findByRole("heading", { name: "Ask to join?" }); - cy.findByRole("textbox"); - cy.findByRole("button", { name: "Request access" }).click(); - - cy.findByRole("heading", { name: "Request to join sent" }); - }); - - // Knocked room should appear in Rooms - cy.findByRole("group", { name: "Rooms" }).findByRole("treeitem", { name: "Cybersecurity" }); - - cy.get(".mx_RoomPreviewBar").within(() => { - cy.findByRole("button", { name: "Cancel request" }).click(); - - cy.findByRole("heading", { name: "Ask to join Cybersecurity?" }); - cy.findByRole("button", { name: "Request access" }); - }); - - cy.findByRole("group", { name: "Historical" }).findByRole("treeitem", { name: "Cybersecurity" }); - }); - - it("should knock into the room then knock is cancelled by another user and room is forgotten", () => { - cy.viewRoomById(roomId); - - cy.get(".mx_RoomPreviewBar").within(() => { - cy.findByRole("button", { name: "Join the discussion" }).click(); - - cy.findByRole("heading", { name: "Ask to join?" }); - cy.findByRole("textbox"); - cy.findByRole("button", { name: "Request access" }).click(); - - cy.findByRole("heading", { name: "Request to join sent" }); - }); - - // Knocked room should appear in Rooms - cy.findByRole("group", { name: "Rooms" }).findByRole("treeitem", { name: "Cybersecurity" }); - - cy.window().then(async (win) => { - // bot waits for knock request from Alice - await waitForRoom(win, bot, roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "m.room.member" && - e.getContent()?.membership === "knock" && - e.getContent()?.displayname === "Alice", - ); - }); - - // bot kicks Alice - await bot.kick(roomId, user.userId); - }); - - // Room should stay in Rooms and have red badge when knock is denied - cy.findByRole("group", { name: "Rooms" }).findByRole("treeitem", { name: "Cybersecurity" }).should("not.exist"); - cy.findByRole("group", { name: "Rooms" }).findByRole("treeitem", { name: "Cybersecurity 1 unread mention." }); - - cy.get(".mx_RoomPreviewBar").within(() => { - cy.findByRole("heading", { name: "You have been denied access" }); - cy.findByRole("button", { name: "Forget this room" }).click(); - }); - - // Room should disappear from the list completely when forgotten - // Should be enabled when issue is fixed: https://github.com/vector-im/element-web/issues/26195 - // cy.findByRole("treeitem", { name: /Cybersecurity/ }).should("not.exist"); - }); - - it("should knock into the public knock room via spotlight", () => { - cy.window().then((win) => { - bot.setRoomDirectoryVisibility(roomId, win.matrixcs.Visibility.Public); - }); - - cy.openSpotlightDialog().within(() => { - cy.spotlightFilter(Filter.PublicRooms); - cy.spotlightResults().eq(0).should("contain", "Cybersecurity"); - cy.spotlightResults().eq(0).click(); - }); - - cy.get(".mx_RoomPreviewBar").within(() => { - cy.findByRole("heading", { name: "Ask to join?" }); - cy.findByRole("textbox"); - cy.findByRole("button", { name: "Request access" }).click(); - - cy.findByRole("heading", { name: "Request to join sent" }); - }); - }); -}); diff --git a/cypress/e2e/knock/manage-knocks.spec.ts b/cypress/e2e/knock/manage-knocks.spec.ts deleted file mode 100644 index f31f206a9b23..000000000000 --- a/cypress/e2e/knock/manage-knocks.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* -Copyright 2023 Mikhail Aheichyk -Copyright 2023 Nordeck IT + Consulting GmbH. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { waitForRoom } from "../utils"; - -describe("Manage Knocks", () => { - let homeserver: HomeserverInstance; - let bot: MatrixClient; - let roomId: string; - - beforeEach(() => { - cy.enableLabsFeature("feature_ask_to_join"); - - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Alice"); - - cy.createRoom({ - name: "Cybersecurity", - initial_state: [ - { - type: "m.room.join_rules", - content: { - join_rule: "knock", - }, - state_key: "", - }, - ], - }).then((newRoomId) => { - roomId = newRoomId; - cy.viewRoomById(newRoomId); - }); - - cy.getBot(homeserver, { displayName: "Bob" }).then(async (_bot) => { - bot = _bot; - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should approve knock using bar", () => { - bot.knockRoom(roomId); - - cy.get(".mx_RoomKnocksBar").within(() => { - cy.findByRole("heading", { name: "Asking to join" }); - cy.findByText(/^Bob/); - cy.findByRole("button", { name: "Approve" }).click(); - }); - - cy.get(".mx_RoomKnocksBar").should("not.exist"); - - cy.findByText("Alice invited Bob"); - }); - - it("should deny knock using bar", () => { - bot.knockRoom(roomId); - - cy.get(".mx_RoomKnocksBar").within(() => { - cy.findByRole("heading", { name: "Asking to join" }); - cy.findByText(/^Bob/); - cy.findByRole("button", { name: "Deny" }).click(); - }); - - cy.get(".mx_RoomKnocksBar").should("not.exist"); - - // Should receive Bob's "m.room.member" with "leave" membership when access is denied - cy.window().then(async (win) => { - await waitForRoom(win, win.mxMatrixClientPeg.get(), roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "m.room.member" && - e.getContent()?.membership === "leave" && - e.getContent()?.displayname === "Bob", - ); - }); - }); - }); - - it("should approve knock using people tab", () => { - bot.knockRoom(roomId, { reason: "Hello, can I join?" }); - - cy.openRoomSettings("People"); - - cy.findByRole("group", { name: "Asking to join" }).within(() => { - cy.findByText(/^Bob/); - cy.findByText("Hello, can I join?"); - cy.findByRole("button", { name: "Approve" }).click(); - - cy.findByText(/^Bob/).should("not.exist"); - }); - - cy.findByText("Alice invited Bob"); - }); - - it("should deny knock using people tab", () => { - bot.knockRoom(roomId, { reason: "Hello, can I join?" }); - - cy.openRoomSettings("People"); - - cy.findByRole("group", { name: "Asking to join" }).within(() => { - cy.findByText(/^Bob/); - cy.findByText("Hello, can I join?"); - cy.findByRole("button", { name: "Deny" }).click(); - - cy.findByText(/^Bob/).should("not.exist"); - }); - - // Should receive Bob's "m.room.member" with "leave" membership when access is denied - cy.window().then(async (win) => { - await waitForRoom(win, win.mxMatrixClientPeg.get(), roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "m.room.member" && - e.getContent()?.membership === "leave" && - e.getContent()?.displayname === "Bob", - ); - }); - }); - }); -}); diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts deleted file mode 100644 index 9b5f0eb67035..000000000000 --- a/cypress/e2e/lazy-loading/lazy-loading.spec.ts +++ /dev/null @@ -1,184 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { MatrixClient } from "../../global"; -import Chainable = Cypress.Chainable; - -interface Charly { - client: MatrixClient; - displayName: string; -} - -describe("Lazy Loading", () => { - let homeserver: HomeserverInstance; - let bob: MatrixClient; - const charlies: Charly[] = []; - - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests - }); - - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Alice"); - - cy.getBot(homeserver, { - displayName: "Bob", - startClient: false, - autoAcceptInvites: false, - }).then((_bob) => { - bob = _bob; - }); - - for (let i = 1; i <= 10; i++) { - const displayName = `Charly #${i}`; - cy.getBot(homeserver, { - displayName, - startClient: false, - autoAcceptInvites: false, - }).then((client) => { - charlies[i - 1] = { displayName, client }; - }); - } - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - const name = "Lazy Loading Test"; - const alias = "#lltest:localhost"; - const charlyMsg1 = "hi bob!"; - const charlyMsg2 = "how's it going??"; - - function setupRoomWithBobAliceAndCharlies(charlies: Charly[]) { - cy.window({ log: false }).then((win) => { - return cy - .wrap( - bob - .createRoom({ - name, - room_alias_name: "lltest", - visibility: win.matrixcs.Visibility.Public, - }) - .then((r) => r.room_id), - { log: false }, - ) - .as("roomId"); - }); - - cy.get("@roomId").then(async (roomId) => { - for (const charly of charlies) { - await charly.client.joinRoom(alias); - } - - for (const charly of charlies) { - cy.botSendMessage(charly.client, roomId, charlyMsg1); - } - for (const charly of charlies) { - cy.botSendMessage(charly.client, roomId, charlyMsg2); - } - - for (let i = 20; i >= 1; --i) { - cy.botSendMessage(bob, roomId, `I will only say this ${i} time(s)!`); - } - }); - - cy.joinRoom(alias); - cy.viewRoomByName(name); - } - - function checkPaginatedDisplayNames(charlies: Charly[]) { - cy.scrollToTop(); - for (const charly of charlies) { - cy.findEventTile(charly.displayName, charlyMsg1).should("exist"); - cy.findEventTile(charly.displayName, charlyMsg2).should("exist"); - } - } - - function openMemberlist(): void { - cy.get(".mx_LegacyRoomHeader").within(() => { - cy.findByRole("button", { name: "Room info" }).click(); - }); - - cy.get(".mx_RoomSummaryCard").within(() => { - cy.findByRole("menuitem", { name: "People" }).click(); // \d represents the number of the room members - }); - } - - function getMemberInMemberlist(name: string): Chainable { - return cy.contains(".mx_MemberList .mx_EntityTile_name", name); - } - - function checkMemberList(charlies: Charly[]) { - getMemberInMemberlist("Alice").should("exist"); - getMemberInMemberlist("Bob").should("exist"); - charlies.forEach((charly) => { - getMemberInMemberlist(charly.displayName).should("exist"); - }); - } - - function checkMemberListLacksCharlies(charlies: Charly[]) { - charlies.forEach((charly) => { - getMemberInMemberlist(charly.displayName).should("not.exist"); - }); - } - - function joinCharliesWhileAliceIsOffline(charlies: Charly[]) { - cy.goOffline(); - - cy.get("@roomId").then(async (roomId) => { - for (const charly of charlies) { - await charly.client.joinRoom(alias); - } - for (let i = 20; i >= 1; --i) { - cy.botSendMessage(charlies[0].client, roomId, "where is charly?"); - } - }); - - cy.goOnline(); - cy.wait(1000); // Ideally we'd await a /sync here but intercepts step on each other from going offline/online - } - - it("should handle lazy loading properly even when offline", () => { - const charly1to5 = charlies.slice(0, 5); - const charly6to10 = charlies.slice(5); - - // Set up room with alice, bob & charlies 1-5 - setupRoomWithBobAliceAndCharlies(charly1to5); - // Alice should see 2 messages from every charly with the correct display name - checkPaginatedDisplayNames(charly1to5); - - openMemberlist(); - checkMemberList(charly1to5); - joinCharliesWhileAliceIsOffline(charly6to10); - checkMemberList(charly6to10); - - cy.get("@roomId").then(async (roomId) => { - for (const charly of charlies) { - await charly.client.leave(roomId); - } - }); - - checkMemberListLacksCharlies(charlies); - }); -}); diff --git a/cypress/e2e/permalinks/permalinks.spec.ts b/cypress/e2e/permalinks/permalinks.spec.ts deleted file mode 100644 index 2a61df26a099..000000000000 --- a/cypress/e2e/permalinks/permalinks.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { ISendEventResponse } from "matrix-js-sdk/src/matrix"; - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import type { CypressBot } from "../../support/bot"; - -const room1Name = "Room 1"; -const room2Name = "Room 2"; -const unknownRoomAlias = "#unknownroom:example.com"; -const permalinkPrefix = "https://matrix.to/#/"; - -const getPill = (label: string) => { - return cy.contains(".mx_Pill_text", new RegExp("^" + label + "$", "g")); -}; - -describe("permalinks", () => { - beforeEach(() => { - cy.startHomeserver("default") - .as("homeserver") - .then((homeserver: HomeserverInstance) => { - cy.initTestUser(homeserver, "Alice"); - - cy.createRoom({ name: room1Name }).as("room1Id"); - cy.createRoom({ name: room2Name }).as("room2Id"); - - cy.getBot(homeserver, { displayName: "Bob" }).as("bob"); - cy.getBot(homeserver, { displayName: "Charlotte" }).as("charlotte"); - cy.getBot(homeserver, { displayName: "Danielle" }).as("danielle"); - }); - }); - - afterEach(() => { - cy.get("@homeserver").then((homeserver: HomeserverInstance) => { - cy.stopHomeserver(homeserver); - }); - }); - - it("shoud render permalinks as expected", () => { - let danielle: CypressBot; - - cy.get("@danielle").then((d) => { - danielle = d; - }); - - cy.viewRoomByName(room1Name); - - cy.all([ - cy.getClient(), - cy.get("@room1Id"), - cy.get("@room2Id"), - cy.get("@bob"), - cy.get("@charlotte"), - ]).then(([client, room1Id, room2Id, bob, charlotte]) => { - cy.inviteUser(room1Id, bob.getUserId()); - cy.botJoinRoom(bob, room1Id); - cy.inviteUser(room2Id, charlotte.getUserId()); - cy.botJoinRoom(charlotte, room2Id); - - cy.botSendMessage(client, room1Id, "At room mention: @room"); - - cy.botSendMessage(client, room1Id, `Permalink to Room 2: ${permalinkPrefix}${room2Id}`); - cy.botSendMessage( - client, - room1Id, - `Permalink to an unknown room alias: ${permalinkPrefix}${unknownRoomAlias}`, - ); - - cy.botSendMessage(bob, room1Id, "Hello").then((result: ISendEventResponse) => { - cy.botSendMessage( - client, - room1Id, - `Permalink to a message in the same room: ${permalinkPrefix}${room1Id}/${result.event_id}`, - ); - }); - cy.botSendMessage(charlotte, room2Id, "Hello").then((result: ISendEventResponse) => { - cy.botSendMessage( - client, - room1Id, - `Permalink to a message in another room: ${permalinkPrefix}${room2Id}/${result.event_id}`, - ); - }); - cy.botSendMessage(client, room1Id, `Permalink to an uknonwn message: ${permalinkPrefix}${room1Id}/$abc123`); - - cy.botSendMessage(client, room1Id, `Permalink to a user in the room: ${permalinkPrefix}${bob.getUserId()}`); - cy.botSendMessage( - client, - room1Id, - `Permalink to a user in another room: ${permalinkPrefix}${charlotte.getUserId()}`, - ); - cy.botSendMessage( - client, - room1Id, - `Permalink to a user with whom alice doesn't share a room: ${permalinkPrefix}${danielle.getUserId()}`, - ); - }); - - cy.get(".mx_RoomView_timeline").within(() => { - getPill("@room"); - - getPill(room2Name); - getPill(unknownRoomAlias); - - getPill("Message from Bob"); - getPill(`Message in ${room2Name}`); - getPill("Message"); - - getPill("Bob"); - getPill("Charlotte"); - // This is the permalink to Danielle's profile. It should only display the MXID - // because the profile is unknown (not sharing any room with Danielle). - getPill(danielle.getSafeUserId()); - }); - - // Exclude various components from the snapshot, for consistency - const percyCSS = - ".mx_cryptoEvent, " + - ".mx_NewRoomIntro, " + - ".mx_MessageTimestamp, " + - ".mx_RoomView_myReadMarker, " + - ".mx_GenericEventListSummary { visibility: hidden !important; }"; - - cy.get(".mx_RoomView_timeline").percySnapshotElement("Permalink rendering", { percyCSS }); - }); -}); diff --git a/cypress/e2e/polls/pollHistory.spec.ts b/cypress/e2e/polls/pollHistory.spec.ts deleted file mode 100644 index dec4fed5afa6..000000000000 --- a/cypress/e2e/polls/pollHistory.spec.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* -Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { MatrixClient } from "../../global"; - -describe("Poll history", () => { - let homeserver: HomeserverInstance; - - type CreatePollOptions = { - title: string; - options: { - "id": string; - "org.matrix.msc1767.text": string; - }[]; - }; - const createPoll = async ({ title, options }: CreatePollOptions, roomId, client: MatrixClient) => { - return await client.sendEvent(roomId, "org.matrix.msc3381.poll.start", { - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": title, - "body": title, - "msgtype": "m.text", - }, - kind: "org.matrix.msc3381.poll.disclosed", - max_selections: 1, - answers: options, - }, - "org.matrix.msc1767.text": "poll fallback text", - }); - }; - - const botVoteForOption = async ( - bot: MatrixClient, - roomId: string, - pollId: string, - optionId: string, - ): Promise => { - // We can't use the js-sdk types for this stuff directly, so manually construct the event. - await bot.sendEvent(roomId, "org.matrix.msc3381.poll.response", { - "m.relates_to": { - rel_type: "m.reference", - event_id: pollId, - }, - "org.matrix.msc3381.poll.response": { - answers: [optionId], - }, - }); - }; - - const endPoll = async (bot: MatrixClient, roomId: string, pollId: string): Promise => { - // We can't use the js-sdk types for this stuff directly, so manually construct the event. - await bot.sendEvent(roomId, "org.matrix.msc3381.poll.end", { - "m.relates_to": { - rel_type: "m.reference", - event_id: pollId, - }, - "org.matrix.msc1767.text": "The poll has ended", - }); - }; - - function openPollHistory(): void { - cy.findByRole("button", { name: "Room info" }).click(); - cy.get(".mx_RoomSummaryCard").within(() => { - cy.findByRole("menuitem", { name: "Poll history" }).click(); - }); - } - - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Tom"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("Should display active and past polls", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { - bot = _bot; - }); - - const pollParams1 = { - title: "Does the polls feature work?", - options: ["Yes", "No", "Maybe"].map((option) => ({ - "id": option, - "org.matrix.msc1767.text": option, - })), - }; - - const pollParams2 = { - title: "Which way", - options: ["Left", "Right"].map((option) => ({ - "id": option, - "org.matrix.msc1767.text": option, - })), - }; - - cy.createRoom({}).as("roomId"); - - cy.get("@roomId").then((roomId) => { - cy.inviteUser(roomId, bot.getUserId()); - cy.visit("/#/room/" + roomId); - // wait until Bob joined - cy.findByText("BotBob joined the room").should("exist"); - }); - - // active poll - cy.get("@roomId") - .then(async (roomId) => { - const { event_id: pollId } = await createPoll(pollParams1, roomId, bot); - await botVoteForOption(bot, roomId, pollId, pollParams1.options[1].id); - return pollId; - }) - .as("pollId1"); - - // ended poll - cy.get("@roomId") - .then(async (roomId) => { - const { event_id: pollId } = await createPoll(pollParams2, roomId, bot); - await botVoteForOption(bot, roomId, pollId, pollParams1.options[1].id); - await endPoll(bot, roomId, pollId); - return pollId; - }) - .as("pollId2"); - - openPollHistory(); - - // these polls are also in the timeline - // focus on the poll history dialog - cy.get(".mx_Dialog").within(() => { - // active poll is in active polls list - // open poll detail - cy.findByText(pollParams1.title).click(); - - // vote in the poll - cy.findByText("Yes").click(); - cy.findByTestId("totalVotes").within(() => { - cy.findByText("Based on 2 votes"); - }); - - // navigate back to list - cy.get(".mx_PollHistory_header").within(() => { - cy.findByRole("button", { name: "Active polls" }).click(); - }); - - // go to past polls list - cy.findByText("Past polls").click(); - - cy.findByText(pollParams2.title).should("exist"); - }); - - // end poll1 while dialog is open - cy.all([cy.get("@roomId"), cy.get("@pollId1")]).then(async ([roomId, pollId]) => { - return endPoll(bot, roomId, pollId); - }); - - cy.get(".mx_Dialog").within(() => { - // both ended polls are in past polls list - cy.findByText(pollParams2.title).should("exist"); - cy.findByText(pollParams1.title).should("exist"); - - cy.findByText("Active polls").click(); - - // no more active polls - cy.findByText("There are no active polls in this room").should("exist"); - }); - }); -}); diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts deleted file mode 100644 index 1a6682a6429c..000000000000 --- a/cypress/e2e/polls/polls.spec.ts +++ /dev/null @@ -1,372 +0,0 @@ -/* -Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { MatrixClient } from "../../global"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { Layout } from "../../../src/settings/enums/Layout"; -import Chainable = Cypress.Chainable; - -const hidePercyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - -describe("Polls", () => { - let homeserver: HomeserverInstance; - - type CreatePollOptions = { - title: string; - options: string[]; - }; - const createPoll = ({ title, options }: CreatePollOptions) => { - if (options.length < 2) { - throw new Error("Poll must have at least two options"); - } - cy.get(".mx_PollCreateDialog").within((pollCreateDialog) => { - cy.findByRole("textbox", { name: "Question or topic" }).type(title); - - options.forEach((option, index) => { - const optionId = `#pollcreate_option_${index}`; - - // click 'add option' button if needed - if (pollCreateDialog.find(optionId).length === 0) { - cy.findByRole("button", { name: "Add option" }).scrollIntoView().click(); - } - cy.get(optionId).scrollIntoView().type(option); - }); - }); - cy.get(".mx_Dialog").within(() => { - cy.findByRole("button", { name: "Create Poll" }).click(); - }); - }; - - const getPollTile = (pollId: string): Chainable => { - return cy.get(`.mx_EventTile[data-scroll-tokens="${pollId}"]`); - }; - - const getPollOption = (pollId: string, optionText: string): Chainable => { - return getPollTile(pollId).contains(".mx_PollOption .mx_StyledRadioButton", optionText); - }; - - const expectPollOptionVoteCount = (pollId: string, optionText: string, votes: number): void => { - getPollOption(pollId, optionText).within(() => { - cy.get(".mx_PollOption_optionVoteCount").should("contain", `${votes} vote`); - }); - }; - - const botVoteForOption = (bot: MatrixClient, roomId: string, pollId: string, optionText: string): void => { - getPollOption(pollId, optionText).within((ref) => { - cy.findByRole("radio") - .invoke("attr", "value") - .then((optionId) => { - // We can't use the js-sdk types for this stuff directly, so manually construct the event. - bot.sendEvent(roomId, "org.matrix.msc3381.poll.response", { - "m.relates_to": { - rel_type: "m.reference", - event_id: pollId, - }, - "org.matrix.msc3381.poll.response": { - answers: [optionId], - }, - }); - }); - }); - }; - - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Tom"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should be creatable and votable", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { - bot = _bot; - }); - - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.inviteUser(roomId, bot.getUserId()); - cy.visit("/#/room/" + roomId); - // wait until Bob joined - cy.findByText("BotBob joined the room").should("exist"); - }); - - cy.openMessageComposerOptions().within(() => { - cy.findByRole("menuitem", { name: "Poll" }).click(); - }); - - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 - //cy.get(".mx_CompoundDialog").percySnapshotElement("Polls Composer"); - - const pollParams = { - title: "Does the polls feature work?", - // Since we're going to take a screenshot anyways, we include some - // non-ASCII characters here to stress test the app's font config - // while we're at it. - options: ["Yes", "Noo⃐o⃑o⃩o⃪o⃫o⃬o⃭o⃮o⃯", "のらねこ Maybe?"], - }; - createPoll(pollParams); - - // Wait for message to send, get its ID and save as @pollId - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) - .invoke("attr", "data-scroll-tokens") - .as("pollId"); - - cy.get("@pollId").then((pollId) => { - getPollTile(pollId).percySnapshotElement("Polls Timeline tile - no votes", { percyCSS: hidePercyCSS }); - - // Bot votes 'Maybe' in the poll - botVoteForOption(bot, roomId, pollId, pollParams.options[2]); - - // no votes shown until I vote, check bots vote has arrived - cy.get(".mx_MPollBody_totalVotes").within(() => { - cy.findByText("1 vote cast. Vote to see the results"); - }); - - // vote 'Maybe' - getPollOption(pollId, pollParams.options[2]).click("topLeft"); - // both me and bot have voted Maybe - expectPollOptionVoteCount(pollId, pollParams.options[2], 2); - - // change my vote to 'Yes' - getPollOption(pollId, pollParams.options[0]).click("topLeft"); - - // 1 vote for yes - expectPollOptionVoteCount(pollId, pollParams.options[0], 1); - // 1 vote for maybe - expectPollOptionVoteCount(pollId, pollParams.options[2], 1); - - // Bot updates vote to 'No' - botVoteForOption(bot, roomId, pollId, pollParams.options[1]); - - // 1 vote for yes - expectPollOptionVoteCount(pollId, pollParams.options[0], 1); - // 1 vote for no - expectPollOptionVoteCount(pollId, pollParams.options[0], 1); - // 0 for maybe - expectPollOptionVoteCount(pollId, pollParams.options[2], 0); - }); - }); - - it("should be editable from context menu if no votes have been cast", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { - bot = _bot; - }); - - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.inviteUser(roomId, bot.getUserId()); - cy.visit("/#/room/" + roomId); - }); - - cy.openMessageComposerOptions().within(() => { - cy.findByRole("menuitem", { name: "Poll" }).click(); - }); - - const pollParams = { - title: "Does the polls feature work?", - options: ["Yes", "No", "Maybe"], - }; - createPoll(pollParams); - - // Wait for message to send, get its ID and save as @pollId - cy.get(".mx_RoomView_body .mx_EventTile") - .contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) - .invoke("attr", "data-scroll-tokens") - .as("pollId"); - - cy.get("@pollId").then((pollId) => { - // Open context menu - getPollTile(pollId).rightclick(); - - // Select edit item - cy.findByRole("menuitem", { name: "Edit" }).click(); - - // Expect poll editing dialog - cy.get(".mx_PollCreateDialog"); - }); - }); - - it("should not be editable from context menu if votes have been cast", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { - bot = _bot; - }); - - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.inviteUser(roomId, bot.getUserId()); - cy.visit("/#/room/" + roomId); - }); - - cy.openMessageComposerOptions().within(() => { - cy.findByRole("menuitem", { name: "Poll" }).click(); - }); - - const pollParams = { - title: "Does the polls feature work?", - options: ["Yes", "No", "Maybe"], - }; - createPoll(pollParams); - - // Wait for message to send, get its ID and save as @pollId - cy.get(".mx_RoomView_body .mx_EventTile") - .contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) - .invoke("attr", "data-scroll-tokens") - .as("pollId"); - - cy.get("@pollId").then((pollId) => { - // Bot votes 'Maybe' in the poll - botVoteForOption(bot, roomId, pollId, pollParams.options[2]); - - // wait for bot's vote to arrive - cy.get(".mx_MPollBody_totalVotes").should("contain", "1 vote cast"); - - // Open context menu - getPollTile(pollId).rightclick(); - - // Select edit item - cy.findByRole("menuitem", { name: "Edit" }).click(); - - // Expect error dialog - cy.get(".mx_ErrorDialog"); - }); - }); - - it("should be displayed correctly in thread panel", () => { - let botBob: MatrixClient; - let botCharlie: MatrixClient; - cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { - botBob = _bot; - }); - cy.getBot(homeserver, { displayName: "BotCharlie" }).then((_bot) => { - botCharlie = _bot; - }); - - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.inviteUser(roomId, botBob.getUserId()); - cy.inviteUser(roomId, botCharlie.getUserId()); - cy.visit("/#/room/" + roomId); - // wait until the bots joined - cy.findByText("BotBob and one other were invited and joined", { timeout: 10000 }).should("exist"); - }); - - cy.openMessageComposerOptions().within(() => { - cy.findByRole("menuitem", { name: "Poll" }).click(); - }); - - const pollParams = { - title: "Does the polls feature work?", - options: ["Yes", "No", "Maybe"], - }; - createPoll(pollParams); - - // Wait for message to send, get its ID and save as @pollId - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) - .invoke("attr", "data-scroll-tokens") - .as("pollId"); - - cy.get("@pollId").then((pollId) => { - // Bob starts thread on the poll - botBob.sendMessage(roomId, pollId, { - body: "Hello there", - msgtype: "m.text", - }); - - // open the thread summary - cy.findByRole("button", { name: "Open thread" }).click(); - - // Bob votes 'Maybe' in the poll - botVoteForOption(botBob, roomId, pollId, pollParams.options[2]); - // Charlie votes 'No' - botVoteForOption(botCharlie, roomId, pollId, pollParams.options[1]); - - // no votes shown until I vote, check votes have arrived in main tl - cy.get(".mx_RoomView_body .mx_MPollBody_totalVotes").within(() => { - cy.findByText("2 votes cast. Vote to see the results").should("exist"); - }); - // and thread view - cy.get(".mx_ThreadView .mx_MPollBody_totalVotes").within(() => { - cy.findByText("2 votes cast. Vote to see the results").should("exist"); - }); - - // Take snapshots of poll on ThreadView - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_ThreadView .mx_EventTile[data-layout='bubble']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with a poll on bubble layout", { - percyCSS: hidePercyCSS, - }); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - cy.get(".mx_ThreadView .mx_EventTile[data-layout='group']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with a poll on group layout", { - percyCSS: hidePercyCSS, - }); - - cy.get(".mx_RoomView_body").within(() => { - // vote 'Maybe' in the main timeline poll - getPollOption(pollId, pollParams.options[2]).click("topLeft"); - // both me and bob have voted Maybe - expectPollOptionVoteCount(pollId, pollParams.options[2], 2); - }); - - cy.get(".mx_ThreadView").within(() => { - // votes updated in thread view too - expectPollOptionVoteCount(pollId, pollParams.options[2], 2); - // change my vote to 'Yes' - getPollOption(pollId, pollParams.options[0]).click("topLeft"); - }); - - // Bob updates vote to 'No' - botVoteForOption(botBob, roomId, pollId, pollParams.options[1]); - - // me: yes, bob: no, charlie: no - const expectVoteCounts = () => { - // I voted yes - expectPollOptionVoteCount(pollId, pollParams.options[0], 1); - // Bob and Charlie voted no - expectPollOptionVoteCount(pollId, pollParams.options[1], 2); - // 0 for maybe - expectPollOptionVoteCount(pollId, pollParams.options[2], 0); - }; - - // check counts are correct in main timeline tile - cy.get(".mx_RoomView_body").within(() => { - expectVoteCounts(); - }); - // and in thread view tile - cy.get(".mx_ThreadView").within(() => { - expectVoteCounts(); - }); - }); - }); -}); diff --git a/cypress/e2e/presence/presence.spec.ts b/cypress/e2e/presence/presence.spec.ts deleted file mode 100644 index 12a3228b4cd2..000000000000 --- a/cypress/e2e/presence/presence.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// -import { HomeserverInstance } from "../../plugins/utils/homeserver"; - -describe("Presence tests", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("renders unreachable presence state correctly", () => { - cy.initTestUser(homeserver, "Janet"); - cy.getBot(homeserver, { displayName: "Bob" }).then((bob) => { - cy.intercept("GET", "**/sync*", (req) => { - req.continue((res) => { - res.body.presence = { - events: [ - { - type: "m.presence", - sender: bob.getUserId(), - content: { - presence: "io.element.unreachable", - currently_active: false, - }, - }, - ], - }; - }); - }); - cy.createRoom({ name: "My Room", invite: [bob.getUserId()] }).then((roomId) => { - cy.viewRoomById(roomId); - }); - cy.findByRole("button", { name: "Room info" }).click(); - cy.get(".mx_RightPanel").within(() => { - cy.contains("People").click(); - }); - cy.get(".mx_EntityTile_unreachable") - .should("contain.text", "Bob") - .should("contain.text", "User's server unreachable"); - }); - }); -}); diff --git a/cypress/e2e/sliding-sync/sliding-sync.spec.ts b/cypress/e2e/sliding-sync/sliding-sync.spec.ts deleted file mode 100644 index ee9beafe1434..000000000000 --- a/cypress/e2e/sliding-sync/sliding-sync.spec.ts +++ /dev/null @@ -1,502 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import _ from "lodash"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { Interception } from "cypress/types/net-stubbing"; - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { ProxyInstance } from "../../plugins/sliding-sync"; - -describe("Sliding Sync", () => { - beforeEach(() => { - cy.startHomeserver("default") - .as("homeserver") - .then((homeserver) => { - cy.startProxy(homeserver).as("proxy"); - }); - - cy.all([cy.get("@homeserver"), cy.get("@proxy")]).then( - ([homeserver, proxy]) => { - cy.enableLabsFeature("feature_sliding_sync"); - - cy.intercept("/config.json?cachebuster=*", (req) => { - return req.continue((res) => { - res.send(200, { - ...res.body, - setting_defaults: { - feature_sliding_sync_proxy_url: `http://localhost:${proxy.port}`, - }, - }); - }); - }); - - cy.initTestUser(homeserver, "Sloth").then(() => { - return cy.window({ log: false }).then(() => { - cy.createRoom({ name: "Test Room" }).as("roomId"); - }); - }); - }, - ); - }); - - afterEach(() => { - cy.get("@homeserver").then(cy.stopHomeserver); - cy.get("@proxy").then(cy.stopProxy); - }); - - // assert order - const checkOrder = (wantOrder: string[]) => { - cy.findByRole("group", { name: "Rooms" }) - .find(".mx_RoomTile_title") - .should((elements) => { - expect( - _.map(elements, (e) => { - return e.textContent; - }), - "rooms are sorted", - ).to.deep.equal(wantOrder); - }); - }; - const bumpRoom = (alias: string) => { - // Send a message into the given room, this should bump the room to the top - cy.get(alias).then((roomId) => { - return cy.sendEvent(roomId, null, "m.room.message", { - body: "Hello world", - msgtype: "m.text", - }); - }); - }; - const createAndJoinBob = () => { - // create a Bob user - cy.get("@homeserver").then((homeserver) => { - return cy - .getBot(homeserver, { - displayName: "Bob", - }) - .as("bob"); - }); - - // invite Bob to Test Room and accept then send a message. - cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { - return cy.inviteUser(roomId, bob.getUserId()).then(() => { - return bob.joinRoom(roomId); - }); - }); - }; - - it.skip("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", () => { - // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }).then(() => cy.findByRole("treeitem", { name: "Apple" })); - cy.createRoom({ name: "Pineapple" }).then(() => cy.findByRole("treeitem", { name: "Pineapple" })); - cy.createRoom({ name: "Orange" }).then(() => cy.findByRole("treeitem", { name: "Orange" })); - - cy.get(".mx_RoomSublist_tiles").within(() => { - cy.findAllByRole("treeitem").should("have.length", 4); // due to the Test Room in beforeEach - }); - - checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); - - cy.findByRole("group", { name: "Rooms" }).within(() => { - cy.get(".mx_RoomSublist_headerContainer") - .realHover() - .findByRole("button", { name: "List options" }) - .click(); - }); - - // force click as the radio button's size is zero - cy.findByRole("menuitemradio", { name: "A-Z" }).click({ force: true }); - - // Assert that the radio button is checked - cy.get(".mx_StyledRadioButton_checked").within(() => { - cy.findByText("A-Z").should("exist"); - }); - - checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); - }); - - it.skip("should move rooms around as new events arrive", () => { - // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }) - .as("roomA") - .then(() => cy.findByRole("treeitem", { name: "Apple" })); - cy.createRoom({ name: "Pineapple" }) - .as("roomP") - .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); - cy.createRoom({ name: "Orange" }) - .as("roomO") - .then(() => cy.findByRole("treeitem", { name: "Orange" })); - - // Select the Test Room - cy.findByRole("treeitem", { name: "Test Room" }).click(); - - checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); - bumpRoom("@roomA"); - checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); - bumpRoom("@roomO"); - checkOrder(["Orange", "Apple", "Pineapple", "Test Room"]); - bumpRoom("@roomO"); - checkOrder(["Orange", "Apple", "Pineapple", "Test Room"]); - bumpRoom("@roomP"); - checkOrder(["Pineapple", "Orange", "Apple", "Test Room"]); - }); - - it.skip("should not move the selected room: it should be sticky", () => { - // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }) - .as("roomA") - .then(() => cy.findByRole("treeitem", { name: "Apple" })); - cy.createRoom({ name: "Pineapple" }) - .as("roomP") - .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); - cy.createRoom({ name: "Orange" }) - .as("roomO") - .then(() => cy.findByRole("treeitem", { name: "Orange" })); - - // Given a list of Orange, Pineapple, Apple - if Pineapple is active and a message is sent in Apple, the list should - // turn into Apple, Pineapple, Orange - the index position of Pineapple never changes even though the list should technically - // be Apple, Orange Pineapple - only when you click on a different room do things reshuffle. - - // Select the Pineapple room - cy.findByRole("treeitem", { name: "Pineapple" }).click(); - checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); - - // Move Apple - bumpRoom("@roomA"); - checkOrder(["Apple", "Pineapple", "Orange", "Test Room"]); - - // Select the Test Room - cy.findByRole("treeitem", { name: "Test Room" }).click(); - - // the rooms reshuffle to match reality - checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); - }); - - it.skip("should show the right unread notifications", () => { - createAndJoinBob(); - - // send a message in the test room: unread notif count shoould increment - cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { - return bob.sendTextMessage(roomId, "Hello World"); - }); - - // check that there is an unread notification (grey) as 1 - cy.findByRole("treeitem", { name: "Test Room 1 unread message." }).contains(".mx_NotificationBadge_count", "1"); - cy.get(".mx_NotificationBadge").should("not.have.class", "mx_NotificationBadge_highlighted"); - - // send an @mention: highlight count (red) should be 2. - cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { - return bob.sendTextMessage(roomId, "Hello Sloth"); - }); - cy.findByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).contains( - ".mx_NotificationBadge_count", - "2", - ); - cy.get(".mx_NotificationBadge").should("have.class", "mx_NotificationBadge_highlighted"); - - // click on the room, the notif counts should disappear - cy.findByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).click(); - cy.findByRole("treeitem", { name: "Test Room" }).should("not.have.class", "mx_NotificationBadge_count"); - }); - - it.skip("should not show unread indicators", () => { - // TODO: for now. Later we should. - createAndJoinBob(); - - // disable notifs in this room (TODO: CS API call?) - cy.findByRole("treeitem", { name: "Test Room" }) - .realHover() - .findByRole("button", { name: "Notification options" }) - .click(); - cy.findByRole("menuitemradio", { name: "Mute room" }).click(); - - // create a new room so we know when the message has been received as it'll re-shuffle the room list - cy.createRoom({ - name: "Dummy", - }); - checkOrder(["Dummy", "Test Room"]); - - cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { - return bob.sendTextMessage(roomId, "Do you read me?"); - }); - // wait for this message to arrive, tell by the room list resorting - checkOrder(["Test Room", "Dummy"]); - - cy.findByRole("treeitem", { name: "Test Room" }).get(".mx_NotificationBadge").should("not.exist"); - }); - - it("should update user settings promptly", () => { - cy.openUserSettings("Preferences"); - cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format") - .should("exist") - .find(".mx_ToggleSwitch_on") - .should("not.exist"); - cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format") - .should("exist") - .find(".mx_ToggleSwitch_ball") - .click(); - cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format", { timeout: 2000 }) - .should("exist") - .find(".mx_ToggleSwitch_on", { timeout: 2000 }) - .should("exist"); - }); - - it.skip("should show and be able to accept/reject/rescind invites", () => { - createAndJoinBob(); - - let clientUserId; - cy.getClient().then((cli) => { - clientUserId = cli.getUserId(); - }); - - // invite Sloth into 3 rooms: - // - roomJoin: will join this room - // - roomReject: will reject the invite - // - roomRescind: will make Bob rescind the invite - let roomJoin; - let roomReject; - let roomRescind; - let bobClient; - cy.get("@bob") - .then((bob) => { - bobClient = bob; - return Promise.all([ - bob.createRoom({ name: "Room to Join" }), - bob.createRoom({ name: "Room to Reject" }), - bob.createRoom({ name: "Room to Rescind" }), - ]); - }) - .then(([join, reject, rescind]) => { - roomJoin = join.room_id; - roomReject = reject.room_id; - roomRescind = rescind.room_id; - return Promise.all([ - bobClient.invite(roomJoin, clientUserId), - bobClient.invite(roomReject, clientUserId), - bobClient.invite(roomRescind, clientUserId), - ]); - }); - - cy.findByRole("group", { name: "Invites" }).within(() => { - // Exclude headerText - cy.get(".mx_RoomSublist_tiles").within(() => { - // Wait for them all to be on the UI - cy.findAllByRole("treeitem").should("have.length", 3); - }); - }); - - // Select the room to join - cy.findByRole("treeitem", { name: "Room to Join" }).click(); - - cy.get(".mx_RoomView").within(() => { - // Accept the invite - cy.findByRole("button", { name: "Accept" }).click(); - }); - - checkOrder(["Room to Join", "Test Room"]); - - // Select the room to reject - cy.findByRole("treeitem", { name: "Room to Reject" }).click(); - - cy.get(".mx_RoomView").within(() => { - // Reject the invite - cy.findByRole("button", { name: "Reject" }).click(); - }); - - cy.findByRole("group", { name: "Invites" }).within(() => { - // Exclude headerText - cy.get(".mx_RoomSublist_tiles").within(() => { - // Wait for the rejected room to disappear - cy.findAllByRole("treeitem").should("have.length", 2); - }); - }); - - // check the lists are correct - checkOrder(["Room to Join", "Test Room"]); - - cy.findByRole("group", { name: "Invites" }) - .find(".mx_RoomTile_title") - .should((elements) => { - expect( - _.map(elements, (e) => { - return e.textContent; - }), - "rooms are sorted", - ).to.deep.equal(["Room to Rescind"]); - }); - - // now rescind the invite - cy.get("@bob").then((bob) => { - return bob.kick(roomRescind, clientUserId); - }); - - cy.findByRole("group", { name: "Rooms" }).within(() => { - // Exclude headerText - cy.get(".mx_RoomSublist_tiles").within(() => { - // Wait for the rescind to take effect and check the joined list once more - cy.findAllByRole("treeitem").should("have.length", 2); - }); - }); - - checkOrder(["Room to Join", "Test Room"]); - }); - - it("should show a favourite DM only in the favourite sublist", () => { - cy.createRoom({ - name: "Favourite DM", - is_direct: true, - }) - .as("room") - .then((roomId) => { - cy.getClient().then((cli) => cli.setRoomTag(roomId, "m.favourite", { order: 0.5 })); - }); - - cy.findByRole("group", { name: "Favourites" }).findByText("Favourite DM").should("exist"); - cy.findByRole("group", { name: "People" }).findByText("Favourite DM").should("not.exist"); - }); - - // 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. - it.skip("should clear the reply to field when swapping rooms", () => { - cy.createRoom({ name: "Other Room" }) - .as("roomA") - .then(() => cy.findByRole("treeitem", { name: "Other Room" })); - cy.get("@roomId").then((roomId) => { - return cy.sendEvent(roomId, null, "m.room.message", { - body: "Hello world", - msgtype: "m.text", - }); - }); - // select the room - cy.findByRole("treeitem", { name: "Test Room" }).click(); - cy.get(".mx_ReplyPreview").should("not.exist"); - // click reply-to on the Hello World message - cy.get(".mx_EventTile_last") - .within(() => { - cy.findByText("Hello world", { timeout: 1000 }); - }) - .realHover() - .findByRole("button", { name: "Reply" }) - .click(); - // check it's visible - cy.get(".mx_ReplyPreview").should("exist"); - // now click Other Room - cy.findByRole("treeitem", { name: "Other Room" }).click(); - // ensure the reply-to disappears - cy.get(".mx_ReplyPreview").should("not.exist"); - // click back - cy.findByRole("treeitem", { name: "Test Room" }).click(); - // ensure the reply-to reappears - cy.get(".mx_ReplyPreview").should("exist"); - }); - - // Regression test for https://github.com/vector-im/element-web/issues/21462 - it.skip("should not cancel replies when permalinks are clicked", () => { - cy.get("@roomId").then((roomId) => { - // we require a first message as you cannot click the permalink text with the avatar in the way - return cy - .sendEvent(roomId, null, "m.room.message", { - body: "First message", - msgtype: "m.text", - }) - .then(() => { - return cy.sendEvent(roomId, null, "m.room.message", { - body: "Permalink me", - msgtype: "m.text", - }); - }) - .then(() => { - cy.sendEvent(roomId, null, "m.room.message", { - body: "Reply to me", - msgtype: "m.text", - }); - }); - }); - // select the room - cy.findByRole("treeitem", { name: "Test Room" }).click(); - cy.get(".mx_ReplyPreview").should("not.exist"); - // click reply-to on the Reply to me message - cy.get(".mx_EventTile") - .last() - .within(() => { - cy.findByText("Reply to me"); - }) - .realHover() - .findByRole("button", { name: "Reply" }) - .click(); - // check it's visible - cy.get(".mx_ReplyPreview").should("exist"); - // now click on the permalink for Permalink me - cy.contains(".mx_EventTile", "Permalink me").find("a").click({ force: true }); - // make sure it is now selected with the little green | - cy.contains(".mx_EventTile_selected", "Permalink me").should("exist"); - // ensure the reply-to does not disappear - cy.get(".mx_ReplyPreview").should("exist"); - }); - - it.skip("should send unsubscribe_rooms for every room switch", () => { - let roomAId: string; - let roomPId: string; - // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }) - .as("roomA") - .then((roomId) => (roomAId = roomId)) - .then(() => cy.findByRole("treeitem", { name: "Apple" })); - - cy.createRoom({ name: "Pineapple" }) - .as("roomP") - .then((roomId) => (roomPId = roomId)) - .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); - cy.createRoom({ name: "Orange" }) - .as("roomO") - .then(() => cy.findByRole("treeitem", { name: "Orange" })); - - // Intercept all calls to /sync - cy.intercept({ method: "POST", url: "**/sync*" }).as("syncRequest"); - - const assertUnsubExists = (interception: Interception, subRoomId: string, unsubRoomId: string) => { - const body = interception.request.body; - // There may be a request without a txn_id, ignore it, as there won't be any subscription changes - if (body.txn_id === undefined) { - return; - } - expect(body.unsubscribe_rooms).eql([unsubRoomId]); - expect(body.room_subscriptions).to.not.have.property(unsubRoomId); - expect(body.room_subscriptions).to.have.property(subRoomId); - }; - - // Select the Test Room - cy.findByRole("treeitem", { name: "Apple" }).click(); - - // and wait for cypress to get the result as alias - cy.wait("@syncRequest").then((interception) => { - // This is the first switch, so no unsubscriptions yet. - assert.isObject(interception.request.body.room_subscriptions, "room_subscriptions is object"); - }); - - // Switch to another room - cy.findByRole("treeitem", { name: "Pineapple" }).click(); - cy.wait("@syncRequest").then((interception) => assertUnsubExists(interception, roomPId, roomAId)); - - // And switch to even another room - cy.findByRole("treeitem", { name: "Apple" }).click(); - cy.wait("@syncRequest").then((interception) => assertUnsubExists(interception, roomPId, roomAId)); - - // TODO: Add tests for encrypted rooms - }); -}); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts deleted file mode 100644 index f1c9842e8c0d..000000000000 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ /dev/null @@ -1,458 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { MatrixClient } from "../../global"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import Chainable = Cypress.Chainable; -import Loggable = Cypress.Loggable; -import Timeoutable = Cypress.Timeoutable; -import Withinable = Cypress.Withinable; -import Shadow = Cypress.Shadow; -import { Filter } from "../../support/settings"; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - roomHeaderName( - options?: Partial, - ): Chainable>; - startDM(name: string): Chainable; - } - } -} - -Cypress.Commands.add( - "roomHeaderName", - (options?: Partial): Chainable> => { - return cy.get(".mx_LegacyRoomHeader_nametext", options); - }, -); - -Cypress.Commands.add("startDM", (name: string) => { - cy.openSpotlightDialog().within(() => { - cy.spotlightFilter(Filter.People); - cy.spotlightSearch().clear().type(name); - cy.wait(1000); // wait for the dialog code to settle - cy.get(".mx_Spinner").should("not.exist"); - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", name); - cy.spotlightResults().eq(0).click(); - }); - // send first message to start DM - cy.findByRole("textbox", { name: "Send a message…" }).should("have.focus").type("Hey!{enter}"); - // The DM room is created at this point, this can take a little bit of time - cy.get(".mx_EventTile_body", { timeout: 30000 }).findByText("Hey!"); - cy.findByRole("group", { name: "People" }).findByText(name); -}); - -describe("Spotlight", () => { - let homeserver: HomeserverInstance; - - const bot1Name = "BotBob"; - let bot1: MatrixClient; - - const bot2Name = "ByteBot"; - let bot2: MatrixClient; - - const room1Name = "247"; - let room1Id: string; - - const room2Name = "Lounge"; - let room2Id: string; - - const room3Name = "Public"; - let room3Id: string; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Jim") - .then(() => - cy.getBot(homeserver, { displayName: bot1Name }).then((_bot1) => { - bot1 = _bot1; - }), - ) - .then(() => - cy.getBot(homeserver, { displayName: bot2Name }).then((_bot2) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - bot2 = _bot2; - }), - ) - .then(() => - cy.window({ log: false }).then(({ matrixcs: { Visibility } }) => { - cy.createRoom({ name: room1Name, visibility: Visibility.Public }).then(async (_room1Id) => { - room1Id = _room1Id; - await bot1.joinRoom(room1Id); - }); - bot2.createRoom({ name: room2Name, visibility: Visibility.Public }).then( - ({ room_id: _room2Id }) => { - room2Id = _room2Id; - bot2.invite(room2Id, bot1.getUserId()); - }, - ); - bot2.createRoom({ - name: room3Name, - visibility: Visibility.Public, - initial_state: [ - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: "world_readable", - }, - }, - ], - }).then(({ room_id: _room3Id }) => { - room3Id = _room3Id; - bot2.invite(room3Id, bot1.getUserId()); - }); - }), - ) - .then(() => { - cy.visit("/#/room/" + room1Id); - cy.get(".mx_RoomSublist_skeletonUI").should("not.exist"); - }); - }); - // wait for the room to have the right name - cy.get(".mx_LegacyRoomHeader").within(() => { - cy.findByText(room1Name); - }); - }); - - afterEach(() => { - cy.visit("/#/home"); - cy.stopHomeserver(homeserver); - }); - - it("should be able to add and remove filters via keyboard", () => { - cy.openSpotlightDialog().within(() => { - cy.wait(1000); // wait for the dialog to settle, otherwise our keypresses might race with an update - - // initially, public spaces should be highlighted (because there are no other suggestions) - cy.get("#mx_SpotlightDialog_button_explorePublicSpaces").should("have.attr", "aria-selected", "true"); - - // hitting enter should enable the public rooms filter - cy.spotlightSearch().type("{enter}"); - cy.get(".mx_SpotlightDialog_filter").should("contain", "Public spaces"); - cy.spotlightSearch().type("{backspace}"); - cy.get(".mx_SpotlightDialog_filter").should("not.exist"); - cy.wait(200); // Again, wait to settle so keypresses arrive correctly - - cy.spotlightSearch().type("{downArrow}"); - cy.get("#mx_SpotlightDialog_button_explorePublicRooms").should("have.attr", "aria-selected", "true"); - cy.spotlightSearch().type("{enter}"); - cy.get(".mx_SpotlightDialog_filter").should("contain", "Public rooms"); - cy.spotlightSearch().type("{backspace}"); - cy.get(".mx_SpotlightDialog_filter").should("not.exist"); - }); - }); - - it("should find joined rooms", () => { - cy.openSpotlightDialog() - .within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightSearch().clear().type(room1Name); - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", room1Name); - cy.spotlightResults().eq(0).click(); - cy.url().should("contain", room1Id); - }) - .then(() => { - cy.roomHeaderName().should("contain", room1Name); - }); - }); - - it("should find known public rooms", () => { - cy.openSpotlightDialog() - .within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.PublicRooms); - cy.spotlightSearch().clear().type(room1Name); - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", room1Name); - cy.spotlightResults().eq(0).should("contain", "View"); - cy.spotlightResults().eq(0).click(); - cy.url().should("contain", room1Id); - }) - .then(() => { - cy.roomHeaderName().should("contain", room1Name); - }); - }); - - it("should find unknown public rooms", () => { - cy.openSpotlightDialog() - .within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.PublicRooms); - cy.spotlightSearch().clear().type(room2Name); - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", room2Name); - cy.spotlightResults().eq(0).should("contain", "Join"); - cy.spotlightResults().eq(0).click(); - cy.url().should("contain", room2Id); - }) - .then(() => { - cy.get(".mx_RoomView_MessageList").should("have.length", 1); - cy.roomHeaderName().should("contain", room2Name); - }); - }); - - it("should find unknown public world readable rooms", () => { - cy.openSpotlightDialog() - .within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.PublicRooms); - cy.spotlightSearch().clear().type(room3Name); - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", room3Name); - cy.spotlightResults().eq(0).should("contain", "View"); - cy.spotlightResults().eq(0).click(); - cy.url().should("contain", room3Id); - }) - .then(() => { - cy.findByRole("button", { name: "Join the discussion" }).click(); - cy.roomHeaderName().should("contain", room3Name); - }); - }); - - // TODO: We currently can’t test finding rooms on other homeservers/other protocols - // We obviously don’t have federation or bridges in cypress tests - it.skip("should find unknown public rooms on other homeservers", () => { - cy.openSpotlightDialog() - .within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.PublicRooms); - cy.spotlightSearch().clear().type(room3Name); - cy.get("[aria-haspopup=true][role=button]").click(); - }) - .then(() => { - cy.contains(".mx_GenericDropdownMenu_Option--header", "matrix.org") - .next("[role=menuitemradio]") - .click(); - cy.wait(3_600_000); - }) - .then(() => - cy.spotlightDialog().within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", room3Name); - cy.spotlightResults().eq(0).should("contain", room3Id); - }), - ); - }); - - it("should find known people", () => { - cy.openSpotlightDialog() - .within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.People); - cy.spotlightSearch().clear().type(bot1Name); - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", bot1Name); - cy.spotlightResults().eq(0).click(); - }) - .then(() => { - cy.roomHeaderName().should("contain", bot1Name); - }); - }); - - /** - * Search sends the correct query to Synapse. - * Synapse doesn't return the user in the result list. - * Waiting for the profile to be available via APIs before the tests didn't help. - * - * https://github.com/matrix-org/synapse/issues/16472 - */ - it.skip("should find unknown people", () => { - cy.openSpotlightDialog() - .within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.People); - cy.spotlightSearch().clear().type(bot2Name); - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", bot2Name); - cy.spotlightResults().eq(0).click(); - }) - .then(() => { - cy.roomHeaderName().should("contain", bot2Name); - }); - }); - - it("should find group DMs by usernames or user ids", () => { - // First we want to share a room with both bots to ensure we’ve got their usernames cached - cy.inviteUser(room1Id, bot2.getUserId()); - - // Starting a DM with ByteBot (will be turned into a group dm later) - cy.openSpotlightDialog().within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.People); - cy.spotlightSearch().clear().type(bot2Name); - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", bot2Name); - cy.spotlightResults().eq(0).click(); - }); - - // Send first message to actually start DM - cy.roomHeaderName().should("contain", bot2Name); - cy.findByRole("textbox", { name: "Send a message…" }).type("Hey!{enter}"); - - // Assert DM exists by checking for the first message and the room being in the room list - cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 }); - cy.findByRole("group", { name: "People" }).should("contain", bot2Name); - - // Invite BotBob into existing DM with ByteBot - cy.getDmRooms(bot2.getUserId()) - .should("have.length", 1) - .then((dmRooms) => cy.getClient().then((client) => client.getRoom(dmRooms[0]))) - .then((groupDm) => { - cy.inviteUser(groupDm.roomId, bot1.getUserId()); - cy.roomHeaderName().should(($element) => expect($element.get(0).innerText).contains(groupDm.name)); - cy.findByRole("group", { name: "People" }).should(($element) => - expect($element.get(0).innerText).contains(groupDm.name), - ); - - // Search for BotBob by id, should return group DM and user - cy.openSpotlightDialog().within(() => { - cy.spotlightFilter(Filter.People); - cy.spotlightSearch().clear().type(bot1.getUserId()); - cy.wait(1000); // wait for the dialog code to settle - cy.spotlightResults().should("have.length", 2); - cy.contains( - ".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", - groupDm.name, - ); - }); - - // Search for ByteBot by id, should return group DM and user - cy.openSpotlightDialog().within(() => { - cy.spotlightFilter(Filter.People); - cy.spotlightSearch().clear().type(bot2.getUserId()); - cy.wait(1000); // wait for the dialog code to settle - cy.spotlightResults().should("have.length", 2); - cy.contains( - ".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", - groupDm.name, - ); - }); - }); - }); - - // Test against https://github.com/vector-im/element-web/issues/22851 - it("should show each person result only once", () => { - cy.openSpotlightDialog().within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.People); - - // 2 rounds of search to simulate the bug conditions. Specifically, the first search - // should have 1 result (not 2) and the second search should also have 1 result (instead - // of the super buggy 3 described by https://github.com/vector-im/element-web/issues/22851) - // - // We search for user ID to trigger the profile lookup within the dialog. - for (let i = 0; i < 2; i++) { - cy.log("Iteration: " + i); - cy.spotlightSearch().clear().type(bot1.getUserId()); - cy.wait(1000); // wait for the dialog code to settle - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", bot1.getUserId()); - } - }); - }); - - it("should allow opening group chat dialog", () => { - cy.openSpotlightDialog() - .within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.People); - cy.spotlightSearch().clear().type(bot2Name); - cy.wait(3000); // wait for the dialog code to settle - cy.spotlightResults().should("have.length", 1); - cy.spotlightResults().eq(0).should("contain", bot2Name); - cy.get(".mx_SpotlightDialog_startGroupChat").should("contain", "Start a group chat"); - cy.get(".mx_SpotlightDialog_startGroupChat").click(); - }) - .then(() => { - cy.findByRole("dialog").should("contain", "Direct Messages"); - }); - }); - - it("should close spotlight after starting a DM", () => { - cy.startDM(bot1Name); - cy.get(".mx_SpotlightDialog").should("have.length", 0); - }); - - it("should show the same user only once", () => { - cy.startDM(bot1Name); - cy.visit("/#/home"); - - cy.openSpotlightDialog().within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.People); - cy.spotlightSearch().clear().type(bot1Name); - cy.wait(3000); // wait for the dialog code to settle - cy.get(".mx_Spinner").should("not.exist"); - cy.spotlightResults().should("have.length", 1); - }); - }); - - it("should be able to navigate results via keyboard", () => { - cy.openSpotlightDialog().within(() => { - cy.wait(500); // Wait for dialog to settle - cy.spotlightFilter(Filter.People); - cy.spotlightSearch().clear().type("b"); - // our debouncing logic only starts the search after a short timeout, - // so we wait a few milliseconds. - cy.wait(1000); - cy.get(".mx_Spinner") - .should("not.exist") - .then(() => { - cy.wait(500); // Wait to settle again - cy.spotlightResults() - .should("have.length", 2) - .then(() => { - cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "true"); - cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "false"); - }); - cy.spotlightSearch() - .type("{downArrow}") - .then(() => { - cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "false"); - cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "true"); - }); - cy.spotlightSearch() - .type("{downArrow}") - .then(() => { - cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "false"); - cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "false"); - }); - cy.spotlightSearch() - .type("{upArrow}") - .then(() => { - cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "false"); - cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "true"); - }); - cy.spotlightSearch() - .type("{upArrow}") - .then(() => { - cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "true"); - cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "false"); - }); - }); - }); - }); -}); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts deleted file mode 100644 index 2192447e0b62..000000000000 --- a/cypress/e2e/threads/threads.spec.ts +++ /dev/null @@ -1,515 +0,0 @@ -/* -Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { MatrixClient } from "../../global"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { Layout } from "../../../src/settings/enums/Layout"; -import Chainable = Cypress.Chainable; - -describe("Threads", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Tom"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - // Flaky: https://github.com/vector-im/element-web/issues/26452 - it.skip("should be usable for a conversation", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { - displayName: "BotBob", - autoAcceptInvites: false, - }).then((_bot) => { - bot = _bot; - }); - - let roomId: string; - cy.createRoom({}) - .then((_roomId) => { - roomId = _roomId; - return cy.inviteUser(roomId, bot.getUserId()); - }) - .then(async () => { - await bot.joinRoom(roomId); - cy.visit("/#/room/" + roomId); - }); - - // Around 200 characters - const MessageLong = - "Hello there. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt " + - "ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi"; - - const ThreadViewGroupSpacingStart = "56px"; // --ThreadView_group_spacing-start - // Exclude timestamp and read marker from snapshots - const percyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - - cy.get(".mx_RoomView_body").within(() => { - // User sends message - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - - // Wait for message to send, get its ID and save as @threadId - cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .invoke("attr", "data-scroll-tokens") - .as("threadId"); - }); - - // Bot starts thread - cy.get("@threadId").then((threadId) => { - bot.sendMessage(roomId, threadId, { - // Send a message long enough to be wrapped to check if avatars inside the ReadReceiptGroup are visible - body: MessageLong, - msgtype: "m.text", - }); - }); - - // User asserts timeline thread summary visible & clicks it - cy.get(".mx_RoomView_body .mx_ThreadSummary") - .within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText(MessageLong).should("exist"); - }) - .click(); - - // Wait until the both messages are read - cy.get(".mx_ThreadView .mx_EventTile_last[data-layout=group]").within(() => { - cy.get(".mx_EventTile_line .mx_MTextBody").findByText(MessageLong).should("exist"); - cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar").should("be.visible"); - - // Make sure the CSS style for spacing is applied to mx_EventTile_line on group/modern layout - cy.get(".mx_EventTile_line").should("have.css", "padding-inline-start", ThreadViewGroupSpacingStart); - }); - - // Take Percy snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) - cy.get(".mx_ThreadView").percySnapshotElement("Initial ThreadView on group layout", { percyCSS }); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_ThreadView .mx_EventTile[data-layout='bubble']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("Initial ThreadView on bubble layout", { percyCSS }); - - // Set the group layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - - cy.get(".mx_ThreadView .mx_EventTile[data-layout='group'].mx_EventTile_last").within(() => { - // Wait until the messages are rendered - cy.get(".mx_EventTile_line .mx_MTextBody").findByText(MessageLong).should("exist"); - - // Make sure the avatar inside ReadReceiptGroup is visible on the group layout - cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar").should("be.visible"); - }); - - // Enable the bubble layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - - cy.get(".mx_ThreadView .mx_EventTile[data-layout='bubble'].mx_EventTile_last").within(() => { - // TODO: remove this after fixing the issue of ReadReceiptGroup being hidden on the bubble layout - // See: https://github.com/vector-im/element-web/issues/23569 - cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar").should("exist"); - - // Make sure the avatar inside ReadReceiptGroup is visible on bubble layout - // TODO: enable this after fixing the issue of ReadReceiptGroup being hidden on the bubble layout - // See: https://github.com/vector-im/element-web/issues/23569 - // cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar").should("be.visible"); - }); - - // Re-enable the group layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - - cy.get(".mx_ThreadView").within(() => { - // User responds in thread - cy.findByRole("textbox", { name: "Send a message…" }).type("Test{enter}"); - }); - - // User asserts summary was updated correctly - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("Test").should("exist"); - }); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // Check reactions and hidden events - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - // Enable hidden events to make the event for reaction displayed - cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); - - // User reacts to message instead - cy.get(".mx_ThreadView").within(() => { - cy.contains(".mx_EventTile .mx_EventTile_line", "Hello there") - .realHover() - .findByRole("toolbar", { name: "Message Actions" }) - .findByRole("button", { name: "React" }) - .click(); - }); - - cy.get(".mx_EmojiPicker").within(() => { - cy.findByRole("textbox").type("wave"); - cy.findByRole("gridcell", { name: "👋" }).click(); - }); - - cy.get(".mx_ThreadView").within(() => { - // Make sure the CSS style for spacing is applied to mx_ReactionsRow on group/modern layout - cy.get(".mx_EventTile[data-layout=group] .mx_ReactionsRow").should( - "have.css", - "margin-inline-start", - ThreadViewGroupSpacingStart, - ); - - // Make sure the CSS style for spacing is applied to the hidden event on group/modern layout - cy.get( - ".mx_GenericEventListSummary[data-layout=group] .mx_EventTile_info.mx_EventTile_last " + - ".mx_EventTile_line", - ).should("have.css", "padding-inline-start", ThreadViewGroupSpacingStart); - }); - - // Take Percy snapshot of group layout (IRC layout is not available on ThreadView) - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with reaction and a hidden event on group layout", { - percyCSS, - }); - - // Enable bubble layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - - // Make sure the CSS style for spacing is applied to the hidden event on bubble layout - cy.get( - ".mx_ThreadView .mx_GenericEventListSummary[data-layout=bubble] .mx_EventTile_info.mx_EventTile_last", - ).within(() => { - cy.get(".mx_EventTile_line .mx_EventTile_content") - // 76px: ThreadViewGroupSpacingStart + 14px + 6px - // 14px: avatar width - // See: _EventTile.pcss - .should("have.css", "margin-inline-start", "76px"); - cy.get(".mx_EventTile_line") - // Make sure the margin is NOT applied to mx_EventTile_line - .should("have.css", "margin-inline-start", "0px"); - }); - - // Take Percy snapshot of bubble layout - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with reaction and a hidden event on bubble layout", { - percyCSS, - }); - - // Disable hidden events - cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, false); - - // Reset to the group layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // Check redactions - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - // User redacts their prior response - cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Test") - .realHover() - .findByRole("button", { name: "Options" }) - .click(); - cy.get(".mx_IconizedContextMenu").within(() => { - cy.findByRole("menuitem", { name: "Remove" }).click(); - }); - cy.get(".mx_TextInputDialog").within(() => { - cy.findByRole("button", { name: "Remove" }).should("have.class", "mx_Dialog_primary").click(); - }); - - cy.get(".mx_ThreadView").within(() => { - // Wait until the response is redacted - cy.get(".mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); - }); - - // Take Percy snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) - cy.get(".mx_ThreadView .mx_EventTile[data-layout='group']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with redacted messages on group layout", { - percyCSS, - }); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_ThreadView .mx_EventTile[data-layout='bubble']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with redacted messages on bubble layout", { - percyCSS, - }); - - // Set the group layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - - // User asserts summary was updated correctly - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText(MessageLong).should("exist"); - }); - - // User closes right panel after clicking back to thread list - cy.get(".mx_ThreadPanel").within(() => { - cy.findByRole("button", { name: "Threads" }).click(); - cy.findByRole("button", { name: "Close" }).click(); - }); - - // Bot responds to thread - cy.get("@threadId").then((threadId) => { - bot.sendMessage(roomId, threadId, { - body: "How are things?", - msgtype: "m.text", - }); - }); - - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("How are things?").should("exist"); - }); - - cy.findByRole("button", { name: "Threads" }) - .should("have.class", "mx_LegacyRoomHeader_button--unread") // User asserts thread list unread indicator - .click(); // User opens thread list - - // User asserts thread with correct root & latest events & unread dot - cy.get(".mx_ThreadPanel .mx_EventTile_last").within(() => { - cy.get(".mx_EventTile_body").findByText("Hello Mr. Bot").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("How are things?").should("exist"); - - // Check the number of the replies - cy.get(".mx_ThreadPanel_replies_amount").findByText("2").should("exist"); - - // Make sure the notification dot is visible - cy.get(".mx_NotificationBadge_visible").should("be.visible"); - - // User opens thread via threads list - cy.get(".mx_EventTile_line").click(); - }); - - // User responds & asserts - cy.get(".mx_ThreadView").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("Great!{enter}"); - }); - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("Great!").should("exist"); - }); - - // User edits & asserts - cy.get(".mx_ThreadView .mx_EventTile_last").within(() => { - cy.findByText("Great!").should("exist"); - cy.get(".mx_EventTile_line").realHover().findByRole("button", { name: "Edit" }).click(); - cy.findByRole("textbox").type(" How about yourself?{enter}"); - }); - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("Great! How about yourself?").should("exist"); - }); - - // User closes right panel - cy.get(".mx_ThreadPanel").within(() => { - cy.findByRole("button", { name: "Close" }).click(); - }); - - // Bot responds to thread and saves the id of their message to @eventId - cy.get("@threadId").then((threadId) => { - cy.wrap( - bot - .sendMessage(roomId, threadId, { - body: "I'm very good thanks", - msgtype: "m.text", - }) - .then((res) => res.event_id), - ).as("eventId"); - }); - - // User asserts - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("I'm very good thanks").should("exist"); - }); - - // Bot edits their latest event - cy.get("@eventId").then((eventId) => { - bot.sendMessage(roomId, { - "body": "* I'm very good thanks :)", - "msgtype": "m.text", - "m.new_content": { - body: "I'm very good thanks :)", - msgtype: "m.text", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: eventId, - }, - }); - }); - - // User asserts - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("I'm very good thanks :)").should("exist"); - }); - }); - - it("can send voice messages", () => { - // Increase viewport size and right-panel size, so that voice messages fit - cy.viewport(1280, 720); - cy.window().then((window) => { - window.localStorage.setItem("mx_rhs_size", "600"); - }); - - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.visit("/#/room/" + roomId); - }); - - // Send message - cy.get(".mx_RoomView_body").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - - // Create thread - cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .findByRole("button", { name: "Reply in thread" }) - .click(); - }); - cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - - cy.openMessageComposerOptions(true).findByRole("menuitem", { name: "Voice Message" }).click(); - cy.wait(3000); - cy.getComposer(true).findByRole("button", { name: "Send voice message" }).click(); - - cy.get(".mx_ThreadView .mx_MVoiceMessageBody").should("have.length", 1); - }); - - it("should send location and reply to the location on ThreadView", () => { - // See: location.spec.ts - const selectLocationShareTypeOption = (shareType: string): Chainable => { - return cy.findByTestId(`share-location-option-${shareType}`); - }; - const submitShareLocation = (): void => { - cy.findByRole("button", { name: "Share location" }).click(); - }; - - let bot: MatrixClient; - cy.getBot(homeserver, { - displayName: "BotBob", - autoAcceptInvites: false, - }).then((_bot) => { - bot = _bot; - }); - - let roomId: string; - cy.createRoom({}) - .then((_roomId) => { - roomId = _roomId; - return cy.inviteUser(roomId, bot.getUserId()); - }) - .then(async () => { - await bot.joinRoom(roomId); - cy.visit("/#/room/" + roomId); - }); - - // Exclude timestamp, read marker, and mapboxgl-map from snapshots - const percyCSS = - ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .mapboxgl-map { visibility: hidden !important; }"; - - cy.get(".mx_RoomView_body").within(() => { - // User sends message - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - - // Wait for message to send, get its ID and save as @threadId - cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .invoke("attr", "data-scroll-tokens") - .as("threadId"); - }); - - // Bot starts thread - cy.get("@threadId").then((threadId) => { - bot.sendMessage(roomId, threadId, { - body: "Hello there", - msgtype: "m.text", - }); - }); - - // User clicks thread summary - cy.get(".mx_RoomView_body .mx_ThreadSummary").click(); - - // User sends location on ThreadView - cy.get(".mx_ThreadView").should("exist"); - cy.openMessageComposerOptions(true).findByRole("menuitem", { name: "Location" }).click(); - selectLocationShareTypeOption("Pin").click(); - cy.get("#mx_LocationPicker_map").click("center"); - submitShareLocation(); - cy.get(".mx_ThreadView .mx_EventTile_last .mx_MLocationBody", { timeout: 10000 }).should("exist"); - - // User replies to the location - cy.get(".mx_ThreadView").within(() => { - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply" }).click(); - - cy.findByRole("textbox", { name: "Reply to thread…" }).type("Please come here.{enter}"); - - // Wait until the reply is sent - cy.get(".mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); - }); - - // Take a snapshot of reply to the shared location - cy.get(".mx_ThreadView").percySnapshotElement("Reply to the location on ThreadView", { percyCSS }); - }); - - it("right panel behaves correctly", () => { - // Create room - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.visit("/#/room/" + roomId); - }); - - // Send message - cy.get(".mx_RoomView_body").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - - // Create thread - cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .findByRole("button", { name: "Reply in thread" }) - .click(); - }); - cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - - // Send message to thread - cy.get(".mx_ThreadPanel").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. User{enter}"); - cy.get(".mx_EventTile_last").findByText("Hello Mr. User").should("exist"); - - // Close thread - cy.findByRole("button", { name: "Close" }).click(); - }); - - // Open existing thread - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .findByRole("button", { name: "Reply in thread" }) - .click(); - cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - - cy.get(".mx_BaseCard").within(() => { - cy.get(".mx_EventTile").first().findByText("Hello Mr. Bot").should("exist"); - cy.get(".mx_EventTile").last().findByText("Hello Mr. User").should("exist"); - }); - }); -}); diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts deleted file mode 100644 index e79f506e2581..000000000000 --- a/cypress/e2e/timeline/timeline.spec.ts +++ /dev/null @@ -1,1031 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import type { ISendEventResponse, EventType, MsgType } from "matrix-js-sdk/src/matrix"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { Layout } from "../../../src/settings/enums/Layout"; -import { MatrixClient } from "../../global"; -import Chainable = Cypress.Chainable; - -// The avatar size used in the timeline -const AVATAR_SIZE = 30; -// The resize method used in the timeline -const AVATAR_RESIZE_METHOD = "crop"; - -const ROOM_NAME = "Test room"; -const OLD_AVATAR = "avatar_image1"; -const NEW_AVATAR = "avatar_image2"; -const OLD_NAME = "Alan"; -const NEW_NAME = "Alan (away)"; - -const getEventTilesWithBodies = (): Chainable => { - return cy.get(".mx_EventTile").filter((_i, e) => e.getElementsByClassName("mx_EventTile_body").length > 0); -}; - -const expectDisplayName = (e: JQuery, displayName: string): void => { - expect(e.find(".mx_DisambiguatedProfile_displayName").text()).to.equal(displayName); -}; - -const expectAvatar = (e: JQuery, avatarUrl: string): void => { - cy.all([cy.window({ log: false }), cy.getClient()]).then(([win, cli]) => { - const size = AVATAR_SIZE * win.devicePixelRatio; - expect(e.find(".mx_BaseAvatar img").attr("src")).to.equal( - // eslint-disable-next-line no-restricted-properties - cli.mxcUrlToHttp(avatarUrl, size, size, AVATAR_RESIZE_METHOD), - ); - }); -}; - -const sendEvent = (roomId: string, html = false): Chainable => { - const content = { - msgtype: "m.text" as MsgType, - body: "Message", - format: undefined, - formatted_body: undefined, - }; - if (html) { - content.format = "org.matrix.custom.html"; - content.formatted_body = "Message"; - } - return cy.sendEvent(roomId, null, "m.room.message" as EventType, content); -}; - -describe("Timeline", () => { - let homeserver: HomeserverInstance; - - let roomId: string; - - let oldAvatarUrl: string; - let newAvatarUrl: string; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, OLD_NAME).then(() => - cy.createRoom({ name: ROOM_NAME }).then((_room1Id) => { - roomId = _room1Id; - }), - ); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - describe("useOnlyCurrentProfiles", () => { - beforeEach(() => { - cy.uploadContent(OLD_AVATAR).then(({ content_uri: url }) => { - oldAvatarUrl = url; - cy.setAvatarUrl(url); - }); - cy.uploadContent(NEW_AVATAR).then(({ content_uri: url }) => { - newAvatarUrl = url; - }); - }); - - it("should show historical profiles if disabled", () => { - cy.setSettingValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, false); - sendEvent(roomId); - cy.setDisplayName("Alan (away)"); - cy.setAvatarUrl(newAvatarUrl); - // XXX: If we send the second event too quickly, there won't be - // enough time for the client to register the profile change - cy.wait(500); - sendEvent(roomId); - cy.viewRoomByName(ROOM_NAME); - - const events = getEventTilesWithBodies(); - - events.should("have.length", 2); - events.each((e, i) => { - if (i === 0) { - expectDisplayName(e, OLD_NAME); - expectAvatar(e, oldAvatarUrl); - } else if (i === 1) { - expectDisplayName(e, NEW_NAME); - expectAvatar(e, newAvatarUrl); - } - }); - }); - - it("should not show historical profiles if enabled", () => { - cy.setSettingValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, true); - sendEvent(roomId); - cy.setDisplayName(NEW_NAME); - cy.setAvatarUrl(newAvatarUrl); - // XXX: If we send the second event too quickly, there won't be - // enough time for the client to register the profile change - cy.wait(500); - sendEvent(roomId); - cy.viewRoomByName(ROOM_NAME); - - const events = getEventTilesWithBodies(); - - events.should("have.length", 2); - events.each((e) => { - expectDisplayName(e, NEW_NAME); - expectAvatar(e, newAvatarUrl); - }); - }); - }); - - describe("configure room", () => { - // Exclude timestamp and read marker from snapshots - const percyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - - beforeEach(() => { - cy.injectAxe(); - }); - - it("should create and configure a room on IRC layout", () => { - cy.visit("/#/room/" + roomId); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - cy.get(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='irc']").within(() => { - cy.get(".mx_GenericEventListSummary_summary") - .findByText(OLD_NAME + " created and configured the room.") - .should("exist"); - }); - - // wait for the date separator to appear to have a stable percy snapshot - cy.get(".mx_TimelineSeparator").should("have.text", "today"); - - cy.get(".mx_MainSplit").percySnapshotElement("Configured room on IRC layout"); - }); - - it("should have an expanded generic event list summary (GELS) on IRC layout", () => { - cy.visit("/#/room/" + roomId); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - - // Wait until configuration is finished - cy.get(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='irc']").within(() => { - cy.get(".mx_GenericEventListSummary_summary") - .findByText(OLD_NAME + " created and configured the room.") - .should("exist"); - }); - - cy.get(".mx_GenericEventListSummary").within(() => { - // Click "expand" link button - cy.findByRole("button", { name: "Expand" }).click(); - - // Assert that the "expand" link button worked - cy.findByRole("button", { name: "Collapse" }).should("exist"); - }); - - cy.get(".mx_MainSplit").percySnapshotElement("Expanded GELS on IRC layout", { percyCSS }); - }); - - it("should have an expanded generic event list summary (GELS) on compact modern/group layout", () => { - cy.visit("/#/room/" + roomId); - - // Set compact modern layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group).setSettingValue( - "useCompactLayout", - null, - SettingLevel.DEVICE, - true, - ); - - // Wait until configuration is finished - cy.get(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']") - .findByText(OLD_NAME + " created and configured the room.") - .should("exist"); - - cy.get(".mx_GenericEventListSummary").within(() => { - // Click "expand" link button - cy.findByRole("button", { name: "Expand" }).click(); - - // Assert that the "expand" link button worked - cy.findByRole("button", { name: "Collapse" }).should("exist"); - }); - - cy.get(".mx_MainSplit").percySnapshotElement("Expanded GELS on modern layout", { percyCSS }); - }); - - it("should click 'collapse' on the first hovered info event line inside GELS on bubble layout", () => { - // This test checks clickability of the "Collapse" link button, which had been covered with - // MessageActionBar's safe area - https://github.com/vector-im/element-web/issues/22864 - - cy.visit("/#/room/" + roomId); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='bubble']").within(() => { - cy.get(".mx_GenericEventListSummary_summary") - .findByText(OLD_NAME + " created and configured the room.") - .should("exist"); - }); - - cy.get(".mx_GenericEventListSummary").within(() => { - // Click "expand" link button - cy.findByRole("button", { name: "Expand" }).click(); - - // Assert that the "expand" link button worked - cy.findByRole("button", { name: "Collapse" }).should("exist"); - }); - - // Make sure spacer is not visible on bubble layout - cy.get(".mx_GenericEventListSummary[data-layout=bubble] .mx_GenericEventListSummary_spacer").should( - "not.be.visible", // See: _GenericEventListSummary.pcss - ); - - // Exclude timestamp from snapshot - const percyCSS = ".mx_MessageTimestamp { visibility: hidden !important; }"; - - // Save snapshot of expanded generic event list summary on bubble layout - cy.get(".mx_MainSplit").percySnapshotElement("Expanded GELS on bubble layout", { percyCSS }); - - cy.get(".mx_GenericEventListSummary").within(() => { - // Click "collapse" link button on the first hovered info event line - cy.get(".mx_GenericEventListSummary_unstyledList .mx_EventTile_info:first-of-type") - .realHover() - .findByRole("toolbar", { name: "Message Actions" }) - .should("be.visible"); - cy.findByRole("button", { name: "Collapse" }).click(); - - // Assert that "collapse" link button worked - cy.findByRole("button", { name: "Expand" }).should("exist"); - }); - - // Save snapshot of collapsed generic event list summary on bubble layout - cy.get(".mx_MainSplit").percySnapshotElement("Collapsed GELS on bubble layout", { percyCSS }); - }); - - it("should add inline start margin to an event line on IRC layout", () => { - cy.visit("/#/room/" + roomId); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - - // Wait until configuration is finished - cy.get(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='irc']").within(() => { - cy.get(".mx_GenericEventListSummary_summary") - .findByText(OLD_NAME + " created and configured the room.") - .should("exist"); - }); - - // Click "expand" link button - cy.get(".mx_GenericEventListSummary").findByRole("button", { name: "Expand" }).click(); - - // Check the event line has margin instead of inset property - // cf. _EventTile.pcss - // --EventTile_irc_line_info-margin-inline-start - // = calc(var(--name-width) + var(--icon-width) + 1 * var(--right-padding)) - // = 80 + 14 + 5 = 99px - - cy.get(".mx_EventTile[data-layout=irc].mx_EventTile_info:first-of-type .mx_EventTile_line") - .should("have.css", "margin-inline-start", "99px") - .should("have.css", "inset-inline-start", "0px"); - - // Exclude timestamp and read marker from snapshot - const percyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - cy.get(".mx_MainSplit").percySnapshotElement("Event line with inline start margin on IRC layout", { - percyCSS, - }); - cy.checkA11y(); - }); - }); - - describe("message displaying", () => { - beforeEach(() => { - cy.injectAxe(); - }); - - const messageEdit = () => { - cy.contains(".mx_EventTile .mx_EventTile_line", "Message") - .realHover() - .findByRole("toolbar", { name: "Message Actions" }) - .findByRole("button", { name: "Edit" }) - .click(); - cy.findByRole("textbox", { name: "Edit message" }).type("Edit{enter}"); - - // Assert that the edited message and the link button are found - cy.contains(".mx_EventTile .mx_EventTile_line", "MessageEdit").within(() => { - // Regex patterns due to the edited date - cy.findByRole("button", { name: /Edited at .*? Click to view edits./ }); - }); - }; - - it("should align generic event list summary with messages and emote on IRC layout", () => { - // This test aims to check: - // 1. Alignment of collapsed GELS (generic event list summary) and messages - // 2. Alignment of expanded GELS and messages - // 3. Alignment of expanded GELS and placeholder of deleted message - // 4. Alignment of expanded GELS, placeholder of deleted message, and emote - - // Exclude timestamp from snapshot of mx_MainSplit - const percyCSS = ".mx_MainSplit .mx_MessageTimestamp { visibility: hidden !important; }"; - - cy.visit("/#/room/" + roomId); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - - // Wait until configuration is finished - cy.get(".mx_GenericEventListSummary_summary").within(() => { - cy.findByText(OLD_NAME + " created and configured the room.").should("exist"); - }); - - // Send messages - cy.get(".mx_RoomView_body").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello again, Mr. Bot{enter}"); - }); - - // Make sure the second message was sent - cy.get(".mx_RoomView_MessageList > .mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); - - // 1. Alignment of collapsed GELS (generic event list summary) and messages - // Check inline start spacing of collapsed GELS - // See: _EventTile.pcss - // .mx_GenericEventListSummary[data-layout="irc"] > .mx_EventTile_line - // = var(--name-width) + var(--icon-width) + var(--MessageTimestamp-width) + 2 * var(--right-padding) - // = 80 + 14 + 46 + 2 * 5 - // = 150px - cy.get(".mx_GenericEventListSummary[data-layout=irc] > .mx_EventTile_line").should( - "have.css", - "padding-inline-start", - "150px", - ); - // Check width and spacing values of elements in .mx_EventTile, which should be equal to 150px - // --right-padding should be applied - cy.get(".mx_EventTile > *").should("have.css", "margin-right", "5px"); - // --name-width width zero inline end margin should be applied - cy.get(".mx_EventTile .mx_DisambiguatedProfile") - .should("have.css", "width", "80px") - .should("have.css", "margin-inline-end", "0px"); - // --icon-width should be applied - cy.get(".mx_EventTile .mx_EventTile_avatar > .mx_BaseAvatar").should("have.css", "width", "14px"); - // var(--MessageTimestamp-width) should be applied - cy.get(".mx_EventTile > a").should("have.css", "min-width", "46px"); - // Record alignment of collapsed GELS and messages on messagePanel - cy.get(".mx_MainSplit").percySnapshotElement("Collapsed GELS and messages on IRC layout", { percyCSS }); - - // 2. Alignment of expanded GELS and messages - // Click "expand" link button - cy.get(".mx_GenericEventListSummary").findByRole("button", { name: "Expand" }).click(); - // Check inline start spacing of info line on expanded GELS - cy.get(".mx_EventTile[data-layout=irc].mx_EventTile_info:first-of-type .mx_EventTile_line") - // See: _EventTile.pcss - // --EventTile_irc_line_info-margin-inline-start - // = 80 + 14 + 1 * 5 - .should("have.css", "margin-inline-start", "99px"); - // Record alignment of expanded GELS and messages on messagePanel - cy.get(".mx_MainSplit").percySnapshotElement("Expanded GELS and messages on IRC layout", { percyCSS }); - - // 3. Alignment of expanded GELS and placeholder of deleted message - // Delete the second (last) message - cy.get(".mx_RoomView_MessageList > .mx_EventTile_last") - .realHover() - .findByRole("button", { name: "Options" }) - .should("be.visible") - .click(); - cy.findByRole("menuitem", { name: "Remove" }).should("be.visible").click(); - // Confirm deletion - cy.get(".mx_Dialog_buttons").within(() => { - cy.findByRole("button", { name: "Remove" }).click(); - }); - // Make sure the dialog was closed and the second (last) message was redacted - cy.get(".mx_Dialog").should("not.exist"); - cy.get(".mx_GenericEventListSummary .mx_EventTile_last .mx_RedactedBody").should("be.visible"); - cy.get(".mx_GenericEventListSummary .mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); - // Record alignment of expanded GELS and placeholder of deleted message on messagePanel - cy.get(".mx_MainSplit").percySnapshotElement("Expanded GELS and with placeholder of deleted message", { - percyCSS, - }); - - // 4. Alignment of expanded GELS, placeholder of deleted message, and emote - // Send a emote - cy.get(".mx_RoomView_body").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("/me says hello to Mr. Bot{enter}"); - }); - // Check inline start margin of its avatar - // Here --right-padding is for the avatar on the message line - // See: _IRCLayout.pcss - // .mx_IRCLayout .mx_EventTile_emote .mx_EventTile_avatar - // = calc(var(--name-width) + var(--icon-width) + 1 * var(--right-padding)) - // = 80 + 14 + 1 * 5 - cy.get(".mx_EventTile_emote .mx_EventTile_avatar").should("have.css", "margin-left", "99px"); - // Make sure emote was sent - cy.get(".mx_EventTile_last.mx_EventTile_emote .mx_EventTile_receiptSent").should("be.visible"); - // Record alignment of expanded GELS, placeholder of deleted message, and emote - cy.get(".mx_MainSplit").percySnapshotElement( - "Expanded GELS and with emote and placeholder of deleted message", - { - percyCSS, - }, - ); - }); - - it("should render EventTiles on IRC, modern (group), and bubble layout", () => { - const percyCSS = - // Hide because flaky - See https://github.com/vector-im/element-web/issues/24957 - ".mx_TopUnreadMessagesBar, " + - // Exclude timestamp and read marker from snapshots - ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - - sendEvent(roomId); - sendEvent(roomId); // check continuation - sendEvent(roomId); // check the last EventTile - - cy.visit("/#/room/" + roomId); - // Send a plain text message - cy.getComposer().type(`Hello{enter}`); - // Send a big emoji - cy.getComposer().type(`🏀{enter}`); - // Send an inline emoji - cy.getComposer().type(`This message has an inline emoji 👒{enter}`); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // IRC layout - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - - // Wait until configuration is finished - cy.get(".mx_GenericEventListSummary_summary").within(() => { - cy.findByText(OLD_NAME + " created and configured the room.").should("exist"); - }); - - cy.get(".mx_MainSplit").percySnapshotElement("EventTiles on IRC layout", { percyCSS }); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // Group/modern layout - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - - cy.get(".mx_RoomView_body[data-layout=group]").within(() => { - // Check that the last EventTile is rendered - cy.get(".mx_EventTile.mx_EventTile_last").should("exist"); - }); - - cy.get(".mx_MainSplit").percySnapshotElement("EventTiles on modern layout", { percyCSS }); - - // Check the same thing for compact layout - cy.setSettingValue("useCompactLayout", null, SettingLevel.DEVICE, true); - - cy.get(".mx_MatrixChat_useCompactLayout").within(() => { - // Check that the last EventTile is rendered - cy.get(".mx_EventTile.mx_EventTile_last").should("exist"); - }); - - cy.get(".mx_MainSplit").percySnapshotElement("EventTiles on compact modern layout", { percyCSS }); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // Message bubble layout - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - - cy.get(".mx_MainSplit").percySnapshotElement("EventTiles on bubble layout", { percyCSS }); - }); - - it("should set inline start padding to a hidden event line", () => { - sendEvent(roomId); - cy.visit("/#/room/" + roomId); - cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); - cy.get(".mx_GenericEventListSummary_summary").within(() => { - cy.findByText(OLD_NAME + " created and configured the room.").should("exist"); - }); - - // Edit message - messageEdit(); - - // Click timestamp to highlight hidden event line - cy.get(".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp").click(); - - // Exclude timestamp and read marker from snapshot - //const percyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - - // should not add inline start padding to a hidden event line on IRC layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - cy.get(".mx_EventTile[data-layout=irc].mx_EventTile_info .mx_EventTile_line").should( - "have.css", - "padding-inline-start", - "0px", - ); - - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 - /*cy.get(".mx_MainSplit").percySnapshotElement("Hidden event line with zero padding on IRC layout", { - percyCSS, - });*/ - - // should add inline start padding to a hidden event line on modern layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - cy.get(".mx_EventTile[data-layout=group].mx_EventTile_info .mx_EventTile_line") - // calc(var(--EventTile_group_line-spacing-inline-start) + 20px) = 64 + 20 = 84px - .should("have.css", "padding-inline-start", "84px"); - - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 - //cy.get(".mx_MainSplit").percySnapshotElement("Hidden event line with padding on modern layout", { - // percyCSS, - //}); - }); - - it("should click view source event toggle", () => { - // This test checks: - // 1. clickability of top left of view source event toggle - // 2. clickability of view source toggle on IRC layout - - // Exclude timestamp from snapshot - const percyCSS = ".mx_MessageTimestamp { visibility: hidden !important; }"; - - sendEvent(roomId); - cy.visit("/#/room/" + roomId); - cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); - cy.get(".mx_GenericEventListSummary_summary").within(() => { - cy.findByText(OLD_NAME + " created and configured the room.").should("exist"); - }); - - // Edit message - messageEdit(); - - // 1. clickability of top left of view source event toggle - - // Click top left of the event toggle, which should not be covered by MessageActionBar's safe area - cy.get(".mx_EventTile_last[data-layout=group] .mx_ViewSourceEvent") - .should("exist") - .realHover() - .within(() => { - cy.findByRole("button", { name: "toggle event" }).click("topLeft"); - }); - - // Make sure the expand toggle works - cy.get(".mx_EventTile_last[data-layout=group] .mx_ViewSourceEvent_expanded") - .should("be.visible") - .realHover() - .within(() => { - cy.findByRole("button", { name: "toggle event" }) - // Check size and position of toggle on expanded view source event - // See: _ViewSourceEvent.pcss - .should("have.css", "height", "12px") // --ViewSourceEvent_toggle-size - .should("have.css", "align-self", "flex-end") - - // Click again to collapse the source - .click("topLeft"); - }); - - // Make sure the collapse toggle works - cy.get(".mx_EventTile_last[data-layout=group] .mx_ViewSourceEvent_expanded").should("not.exist"); - - // 2. clickability of view source toggle on IRC layout - - // Enable IRC layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - - // Hover the view source toggle on IRC layout - cy.get(".mx_GenericEventListSummary[data-layout=irc] .mx_EventTile .mx_ViewSourceEvent") - .should("exist") - .realHover() - .percySnapshotElement("Hovered hidden event line on IRC layout", { percyCSS }); - - // Click view source event toggle - cy.get(".mx_GenericEventListSummary[data-layout=irc] .mx_EventTile .mx_ViewSourceEvent") - .should("exist") - .realHover() - .within(() => { - cy.findByRole("button", { name: "toggle event" }).click("topLeft"); - }); - - // Make sure the expand toggle worked - cy.get(".mx_EventTile[data-layout=irc] .mx_ViewSourceEvent_expanded").should("be.visible"); - }); - - it("should render file size in kibibytes on a file tile", () => { - cy.visit("/#/room/" + roomId); - cy.get(".mx_GenericEventListSummary_summary").within(() => { - cy.findByText(OLD_NAME + " created and configured the room.").should("exist"); - }); - - // Upload a file from the message composer - cy.get(".mx_MessageComposer_actions input[type='file']").selectFile( - "cypress/fixtures/matrix-org-client-versions.json", - { force: true }, - ); - - cy.get(".mx_Dialog").within(() => { - // Click "Upload" button - cy.findByRole("button", { name: "Upload" }).click(); - }); - - // Wait until the file is sent - cy.get(".mx_RoomView_statusArea_expanded").should("not.exist"); - cy.get(".mx_EventTile.mx_EventTile_last .mx_EventTile_receiptSent").should("exist"); - - // Assert that the file size is displayed in kibibytes (1024 bytes), not kilobytes (1000 bytes) - // See: https://github.com/vector-im/element-web/issues/24866 - cy.get(".mx_EventTile_last").within(() => { - // actual file size in kibibytes - cy.get(".mx_MFileBody_info_filename") - .findByText(/1.12 KB/) - .should("exist"); - }); - }); - - it("should render url previews", () => { - cy.intercept("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*", { - statusCode: 200, - fixture: "riot.png", - headers: { - "Content-Type": "image/png", - }, - }).as("mxc"); - cy.intercept("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*", { - statusCode: 200, - body: { - "og:title": "Element Call", - "og:description": null, - "og:image:width": 48, - "og:image:height": 48, - "og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV", - "og:image:type": "image/png", - "matrix:image:size": 2121, - }, - headers: { - "Content-Type": "application/json", - }, - }).as("preview_url"); - - cy.sendEvent(roomId, null, "m.room.message" as EventType, { - msgtype: "m.text" as MsgType, - body: "https://call.element.io/", - }); - cy.visit("/#/room/" + roomId); - - cy.get(".mx_LinkPreviewWidget").should("exist").findByText("Element Call"); - - cy.wait("@preview_url"); - cy.wait("@mxc"); - - cy.checkA11y(); - - // Exclude timestamp and read marker from snapshot - const percyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - cy.get(".mx_EventTile_last").percySnapshotElement("URL Preview", { - percyCSS, - widths: [800, 400], - }); - }); - - describe("on search results panel", () => { - it("should highlight search result words regardless of formatting", () => { - sendEvent(roomId); - sendEvent(roomId, true); - cy.visit("/#/room/" + roomId); - - cy.get(".mx_LegacyRoomHeader").findByRole("button", { name: "Search" }).click(); - - cy.get(".mx_SearchBar").percySnapshotElement("Search bar on the timeline", { - // Emulate narrow timeline - widths: [320, 640], - }); - - cy.get(".mx_SearchBar_input").findByRole("textbox").type("Message{enter}"); - - cy.get(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight").should("exist"); - cy.get(".mx_RoomView_searchResultsPanel").percySnapshotElement("Highlighted search results"); - }); - - it("should render a fully opaque textual event", () => { - const stringToSearch = "Message"; // Same with string sent with sendEvent() - - sendEvent(roomId); - - cy.visit("/#/room/" + roomId); - - // Open a room setting dialog - cy.findByRole("button", { name: "Room options" }).click(); - cy.findByRole("menuitem", { name: "Settings" }).click(); - - // Set a room topic to render a TextualEvent - cy.findByRole("textbox", { name: "Room Topic" }).type(`This is a room for ${stringToSearch}.`); - cy.findByRole("button", { name: "Save" }).click(); - - cy.closeDialog(); - - // Assert that the TextualEvent is rendered - cy.findByText(`${OLD_NAME} changed the topic to "This is a room for ${stringToSearch}.".`) - .should("exist") - .should("have.class", "mx_TextualEvent"); - - // Display the room search bar - cy.get(".mx_LegacyRoomHeader").findByRole("button", { name: "Search" }).click(); - - // Search the string to display both the message and TextualEvent on search results panel - cy.get(".mx_SearchBar").within(() => { - cy.findByRole("textbox").type(`${stringToSearch}{enter}`); - }); - - // On search results panel - cy.get(".mx_RoomView_searchResultsPanel").within(() => { - // Assert that contextual event tiles are translucent - cy.get(".mx_EventTile.mx_EventTile_contextual").should("have.css", "opacity", "0.4"); - - // Assert that the TextualEvent is fully opaque (visually solid). - cy.get(".mx_EventTile .mx_TextualEvent").should("have.css", "opacity", "1"); - }); - - cy.get(".mx_RoomView_searchResultsPanel").percySnapshotElement("Search results - with TextualEvent"); - }); - }); - }); - - describe("message sending", () => { - const MESSAGE = "Hello world"; - const reply = "Reply"; - const viewRoomSendMessageAndSetupReply = () => { - // View room - cy.visit("/#/room/" + roomId); - - // Send a message - cy.getComposer().type(`${MESSAGE}{enter}`); - - // Reply to the message - cy.get(".mx_EventTile_last") - .within(() => { - cy.findByText(MESSAGE); - }) - .realHover() - .findByRole("button", { name: "Reply" }) - .click(); - }; - - // For clicking the reply button on the last line - const clickButtonReply = () => { - cy.get(".mx_RoomView_MessageList").within(() => { - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply" }).click(); - }); - }; - - it("can reply with a text message", () => { - viewRoomSendMessageAndSetupReply(); - - cy.getComposer().type(`${reply}{enter}`); - - cy.get(".mx_RoomView_body").within(() => { - cy.get(".mx_EventTile_last .mx_EventTile_line").within(() => { - cy.get(".mx_ReplyTile .mx_MTextBody").within(() => { - cy.findByText(MESSAGE).should("exist"); - }); - - cy.findByText(reply).should("have.length", 1); - }); - }); - }); - - it("can reply with a voice message", () => { - viewRoomSendMessageAndSetupReply(); - - cy.openMessageComposerOptions().within(() => { - cy.findByRole("menuitem", { name: "Voice Message" }).click(); - }); - - // Record an empty message - cy.wait(3000); - - cy.get(".mx_RoomView_body").within(() => { - cy.get(".mx_MessageComposer").findByRole("button", { name: "Send voice message" }).click(); - - cy.get(".mx_EventTile_last .mx_EventTile_line").within(() => { - cy.get(".mx_ReplyTile .mx_MTextBody").within(() => { - cy.findByText(MESSAGE).should("exist"); - }); - - cy.get(".mx_MVoiceMessageBody").should("have.length", 1); - }); - }); - }); - - it("should not be possible to send flag with regional emojis", () => { - cy.visit("/#/room/" + roomId); - - // Send a message - cy.getComposer().type(":regional_indicator_a"); - cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_a:").click(); - cy.getComposer().type(":regional_indicator_r"); - cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_r:").click(); - cy.getComposer().type(" :regional_indicator_z"); - cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_z:").click(); - cy.getComposer().type(":regional_indicator_a"); - cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_a:").click(); - cy.getComposer().type("{enter}"); - - cy.get(".mx_RoomView_body .mx_EventTile .mx_EventTile_line .mx_MTextBody .mx_EventTile_bigEmoji") - .children() - .should("have.length", 4); - }); - - it("should display a reply chain", () => { - let bot: MatrixClient; - const reply2 = "Reply again"; - - cy.visit("/#/room/" + roomId); - - // Wait until configuration is finished - cy.get(".mx_GenericEventListSummary_summary").within(() => { - cy.findByText(OLD_NAME + " created and configured the room.").should("exist"); - }); - - // Create a bot "BotBob" and invite it - cy.getBot(homeserver, { - displayName: "BotBob", - autoAcceptInvites: false, - }).then((_bot) => { - bot = _bot; - cy.inviteUser(roomId, bot.getUserId()); - bot.joinRoom(roomId); - - // Make sure the bot joined the room - cy.get(".mx_GenericEventListSummary .mx_EventTile_info.mx_EventTile_last").within(() => { - cy.findByText("BotBob joined the room").should("exist"); - }); - - // Have bot send MESSAGE to roomId - cy.botSendMessage(bot, roomId, MESSAGE); - }); - - // Assert that MESSAGE is found - cy.findByText(MESSAGE); - - // Reply to the message - clickButtonReply(); - cy.getComposer().type(`${reply}{enter}`); - - // Make sure 'reply' was sent - cy.get(".mx_RoomView_body .mx_EventTile_last").within(() => { - cy.findByText(reply).should("exist"); - }); - - // Reply again to create a replyChain - clickButtonReply(); - cy.getComposer().type(`${reply2}{enter}`); - - // Assert that 'reply2' was sent - cy.get(".mx_RoomView_body .mx_EventTile_last").within(() => { - cy.findByText(reply2).should("exist"); - }); - - cy.get(".mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); - - // Exclude timestamp and read marker from snapshot - const percyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - - // Check the margin value of ReplyChains of EventTile at the bottom on IRC layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - cy.get(".mx_EventTile_last[data-layout='irc'] .mx_ReplyChain").should("have.css", "margin", "0px"); - - // Take a snapshot on IRC layout - // Note that because zero margin is applied to mx_ReplyChain, the left borders of two mx_ReplyChain - // components may seem to be connected to one. - cy.get(".mx_EventTile_last").percySnapshotElement("EventTile with reply chains on IRC layout", { - percyCSS, - }); - - // Check the margin value of ReplyChains of EventTile at the bottom on group/modern layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - cy.get(".mx_EventTile_last[data-layout='group'] .mx_ReplyChain").should("have.css", "margin-bottom", "8px"); - - // Take a snapshot on modern layout - cy.get(".mx_EventTile_last").percySnapshotElement("EventTile with reply chains on modern layout", { - percyCSS, - }); - - // Check the margin value of ReplyChains of EventTile at the bottom on group/modern compact layout - cy.setSettingValue("useCompactLayout", null, SettingLevel.DEVICE, true); - cy.get(".mx_EventTile_last[data-layout='group'] .mx_ReplyChain").should("have.css", "margin-bottom", "4px"); - - // Take a snapshot on compact modern layout - cy.get(".mx_EventTile_last").percySnapshotElement("EventTile with reply chains on compact modern layout", { - percyCSS, - }); - - // Check the margin value of ReplyChains of EventTile at the bottom on bubble layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_EventTile_last[data-layout='bubble'] .mx_ReplyChain").should( - "have.css", - "margin-bottom", - "8px", - ); - - // Take a snapshot on bubble layout - cy.get(".mx_EventTile_last").percySnapshotElement("EventTile with reply chains on bubble layout", { - percyCSS, - }); - }); - - it("should send, reply, and display long strings without overflowing", () => { - // Max 256 characters for display name - const LONG_STRING = - "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut " + - "et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut " + - "aliquip"; - - // Create a bot with a long display name - let bot: MatrixClient; - cy.getBot(homeserver, { - displayName: LONG_STRING, - autoAcceptInvites: false, - }).then((_bot) => { - bot = _bot; - }); - - // Create another room with a long name, invite the bot, and open the room - cy.createRoom({ name: LONG_STRING }) - .as("testRoomId") - .then((_roomId) => { - roomId = _roomId; - cy.inviteUser(roomId, bot.getUserId()); - bot.joinRoom(roomId); - cy.visit("/#/room/" + roomId); - }); - - // Wait until configuration is finished - cy.get(".mx_GenericEventListSummary_summary").within(() => { - cy.findByText(OLD_NAME + " created and configured the room.").should("exist"); - }); - - // Set the display name to "LONG_STRING 2" in order to avoid a warning in Percy tests from being triggered - // due to the generated random mxid being displayed inside the GELS summary. - cy.setDisplayName(`${LONG_STRING} 2`); - - // Have the bot send a long message - cy.get("@testRoomId").then((roomId) => { - bot.sendMessage(roomId, { - body: LONG_STRING, - msgtype: "m.text", - }); - }); - - // Wait until the message is rendered - cy.get(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").within(() => { - cy.findByText(LONG_STRING); - }); - - // Reply to the message - clickButtonReply(); - cy.getComposer().type(`${reply}{enter}`); - - // Make sure the reply tile is rendered - cy.get(".mx_EventTile_last .mx_EventTile_line").within(() => { - cy.get(".mx_ReplyTile .mx_MTextBody").within(() => { - cy.findByText(LONG_STRING).should("exist"); - }); - - cy.findByText(reply).should("have.length", 1); - }); - - // Change the viewport size - cy.viewport(1600, 1200); - - // Exclude timestamp and read marker from snapshots - //const percyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - - // Make sure the strings do not overflow on IRC layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - // Scroll to the bottom to have Percy take a snapshot of the whole viewport - cy.get(".mx_ScrollPanel").scrollTo("bottom", { ensureScrollable: false }); - // Assert that both avatar in the introduction and the last message are visible at the same time - cy.get(".mx_NewRoomIntro .mx_BaseAvatar").should("be.visible"); - cy.get(".mx_EventTile_last[data-layout='irc']").within(() => { - cy.get(".mx_MTextBody").should("be.visible"); - cy.get(".mx_EventTile_receiptSent").should("be.visible"); // rendered at the bottom of EventTile - }); - // Take a snapshot in IRC layout - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 - //cy.get(".mx_ScrollPanel").percySnapshotElement("Long strings with a reply on IRC layout", { percyCSS }); - - // Make sure the strings do not overflow on modern layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - cy.get(".mx_ScrollPanel").scrollTo("bottom", { ensureScrollable: false }); // Scroll again in case - cy.get(".mx_NewRoomIntro .mx_BaseAvatar").should("be.visible"); - cy.get(".mx_EventTile_last[data-layout='group']").within(() => { - cy.get(".mx_MTextBody").should("be.visible"); - cy.get(".mx_EventTile_receiptSent").should("be.visible"); - }); - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 - //cy.get(".mx_ScrollPanel").percySnapshotElement("Long strings with a reply on modern layout", { percyCSS }); - - // Make sure the strings do not overflow on bubble layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_ScrollPanel").scrollTo("bottom", { ensureScrollable: false }); // Scroll again in case - cy.get(".mx_NewRoomIntro .mx_BaseAvatar").should("be.visible"); - cy.get(".mx_EventTile_last[data-layout='bubble']").within(() => { - cy.get(".mx_MTextBody").should("be.visible"); - cy.get(".mx_EventTile_receiptSent").should("be.visible"); - }); - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 - //cy.get(".mx_ScrollPanel").percySnapshotElement("Long strings with a reply on bubble layout", { percyCSS }); - }); - }); -}); diff --git a/cypress/e2e/utils.ts b/cypress/e2e/utils.ts deleted file mode 100644 index 0ffb125daec5..000000000000 --- a/cypress/e2e/utils.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* -Copyright 2023 Mikhail Aheichyk -Copyright 2023 Nordeck IT + Consulting GmbH. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import type { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; - -/** - * Resolves when room state matches predicate. - * @param win window object - * @param matrixClient MatrixClient instance that can be user or bot - * @param roomId room id to find room and check - * @param predicate defines condition that is used to check the room state - */ -export function waitForRoom( - win: Cypress.AUTWindow, - matrixClient: MatrixClient, - roomId: string, - predicate: (room: Room) => boolean, -): Promise { - return new Promise((resolve, reject) => { - const room = matrixClient.getRoom(roomId); - - if (predicate(room)) { - resolve(); - return; - } - - function onEvent(ev: MatrixEvent) { - if (ev.getRoomId() !== roomId) return; - - if (predicate(room)) { - matrixClient.removeListener(win.matrixcs.ClientEvent.Event, onEvent); - resolve(); - } - } - - matrixClient.on(win.matrixcs.ClientEvent.Event, onEvent); - }); -} diff --git a/cypress/e2e/widgets/events.spec.ts b/cypress/e2e/widgets/events.spec.ts deleted file mode 100644 index 58e4c096791f..000000000000 --- a/cypress/e2e/widgets/events.spec.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* -Copyright 2022 Mikhail Aheichyk -Copyright 2022 Nordeck IT + Consulting GmbH. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { IWidget } from "matrix-widget-api/src/interfaces/IWidget"; - -import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; -import { waitForRoom } from "../utils"; - -const DEMO_WIDGET_ID = "demo-widget-id"; -const DEMO_WIDGET_NAME = "Demo Widget"; -const DEMO_WIDGET_TYPE = "demo"; -const ROOM_NAME = "Demo"; - -const DEMO_WIDGET_HTML = ` - - - Demo Widget - - - - - - -`; - -describe("Widget Events", () => { - let homeserver: HomeserverInstance; - let user: UserCredentials; - let bot: MatrixClient; - let demoWidgetUrl: string; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Mike").then((_user) => { - user = _user; - }); - cy.getBot(homeserver, { displayName: "Bot", autoAcceptInvites: true }).then((_bot) => { - bot = _bot; - }); - }); - cy.serveHtmlFile(DEMO_WIDGET_HTML).then((url) => { - demoWidgetUrl = url; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should be updated if user is re-invited into the room with updated state event", () => { - cy.createRoom({ - name: ROOM_NAME, - invite: [bot.getUserId()], - }).then((roomId) => { - // setup widget via state event - cy.getClient() - .then(async (matrixClient) => { - const content: IWidget = { - id: DEMO_WIDGET_ID, - creatorUserId: "somebody", - type: DEMO_WIDGET_TYPE, - name: DEMO_WIDGET_NAME, - url: demoWidgetUrl, - }; - await matrixClient.sendStateEvent(roomId, "im.vector.modular.widgets", content, DEMO_WIDGET_ID); - }) - .as("widgetEventSent"); - - // set initial layout - cy.getClient() - .then(async (matrixClient) => { - const content = { - widgets: { - [DEMO_WIDGET_ID]: { - container: "top", - index: 1, - width: 100, - height: 0, - }, - }, - }; - await matrixClient.sendStateEvent(roomId, "io.element.widgets.layout", content, ""); - }) - .as("layoutEventSent"); - - // open the room - cy.viewRoomByName(ROOM_NAME); - - // approve capabilities - cy.get(".mx_WidgetCapabilitiesPromptDialog").within(() => { - cy.findByRole("button", { name: "Approve" }).click(); - }); - - cy.all([cy.get("@widgetEventSent"), cy.get("@layoutEventSent")]).then(async () => { - // bot creates a new room with 'm.room.topic' - const { room_id: roomNew } = await bot.createRoom({ - name: "New room", - initial_state: [ - { - type: "m.room.topic", - state_key: "", - content: { - topic: "topic initial", - }, - }, - ], - }); - - await bot.invite(roomNew, user.userId); - - // widget should receive 'm.room.topic' event after invite - cy.window().then(async (win) => { - await waitForRoom(win, win.mxMatrixClientPeg.get(), roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "net.widget_echo" && - e.getContent().type === "m.room.topic" && - e.getContent().content.topic === "topic initial", - ); - }); - }); - - // update the topic - await bot.sendStateEvent( - roomNew, - "m.room.topic", - { - topic: "topic updated", - }, - "", - ); - - await bot.invite(roomNew, user.userId, "something changed in the room"); - - // widget should receive updated 'm.room.topic' event after re-invite - cy.window().then(async (win) => { - await waitForRoom(win, win.mxMatrixClientPeg.get(), roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "net.widget_echo" && - e.getContent().type === "m.room.topic" && - e.getContent().content.topic === "topic updated", - ); - }); - }); - }); - }); - }); -}); diff --git a/cypress/e2e/widgets/layout.spec.ts b/cypress/e2e/widgets/layout.spec.ts deleted file mode 100644 index 16470fd5a0be..000000000000 --- a/cypress/e2e/widgets/layout.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* -Copyright 2022 Oliver Sand -Copyright 2022 Nordeck IT + Consulting GmbH. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { IWidget } from "matrix-widget-api"; - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; - -const ROOM_NAME = "Test Room"; -const WIDGET_ID = "fake-widget"; -const WIDGET_HTML = ` - - - Fake Widget - - - Hello World - - -`; - -describe("Widget Layout", () => { - let widgetUrl: string; - let homeserver: HomeserverInstance; - let roomId: string; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Sally"); - }); - cy.serveHtmlFile(WIDGET_HTML).then((url) => { - widgetUrl = url; - }); - - cy.createRoom({ - name: ROOM_NAME, - }).then((id) => { - roomId = id; - - // setup widget via state event - cy.getClient() - .then(async (matrixClient) => { - const content: IWidget = { - id: WIDGET_ID, - creatorUserId: "somebody", - type: "widget", - name: "widget", - url: widgetUrl, - }; - await matrixClient.sendStateEvent(roomId, "im.vector.modular.widgets", content, WIDGET_ID); - }) - .as("widgetEventSent"); - - // set initial layout - cy.getClient() - .then(async (matrixClient) => { - const content = { - widgets: { - [WIDGET_ID]: { - container: "top", - index: 1, - width: 100, - height: 0, - }, - }, - }; - await matrixClient.sendStateEvent(roomId, "io.element.widgets.layout", content, ""); - }) - .as("layoutEventSent"); - }); - - cy.all([cy.get("@widgetEventSent"), cy.get("@layoutEventSent")]).then(() => { - // open the room - cy.viewRoomByName(ROOM_NAME); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should be set properly", () => { - cy.get(".mx_AppsDrawer").percySnapshotElement("Widgets drawer on the timeline (AppsDrawer)"); - }); - - it("manually resize the height of the top container layout", () => { - cy.get('iframe[title="widget"]').invoke("height").should("be.lessThan", 250); - - cy.get(".mx_AppsDrawer_resizer_container_handle") - .trigger("mousedown") - .trigger("mousemove", { clientX: 0, clientY: 550, force: true }) - .trigger("mouseup", { clientX: 0, clientY: 550, force: true }); - - cy.get('iframe[title="widget"]').invoke("height").should("be.greaterThan", 400); - }); - - it("programatically resize the height of the top container layout", () => { - cy.get('iframe[title="widget"]').invoke("height").should("be.lessThan", 250); - - cy.getClient().then(async (matrixClient) => { - const content = { - widgets: { - [WIDGET_ID]: { - container: "top", - index: 1, - width: 100, - height: 100, - }, - }, - }; - await matrixClient.sendStateEvent(roomId, "io.element.widgets.layout", content, ""); - }); - - cy.get('iframe[title="widget"]').invoke("height").should("be.greaterThan", 400); - }); -}); diff --git a/cypress/e2e/widgets/widget-pip-close.spec.ts b/cypress/e2e/widgets/widget-pip-close.spec.ts deleted file mode 100644 index ca717947d0bd..000000000000 --- a/cypress/e2e/widgets/widget-pip-close.spec.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* -Copyright 2022 Mikhail Aheichyk -Copyright 2022 Nordeck IT + Consulting GmbH. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { IWidget } from "matrix-widget-api/src/interfaces/IWidget"; - -import type { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; - -const DEMO_WIDGET_ID = "demo-widget-id"; -const DEMO_WIDGET_NAME = "Demo Widget"; -const DEMO_WIDGET_TYPE = "demo"; -const ROOM_NAME = "Demo"; - -const DEMO_WIDGET_HTML = ` - - - Demo Widget - - - - - - -`; - -// mostly copied from src/utils/WidgetUtils.waitForRoomWidget with small modifications -function waitForRoomWidget(win: Cypress.AUTWindow, widgetId: string, roomId: string, add: boolean): Promise { - const matrixClient = win.mxMatrixClientPeg.get(); - - return new Promise((resolve, reject) => { - function eventsInIntendedState(evList) { - const widgetPresent = evList.some((ev) => { - return ev.getContent() && ev.getContent()["id"] === widgetId; - }); - if (add) { - return widgetPresent; - } else { - return !widgetPresent; - } - } - - const room = matrixClient.getRoom(roomId); - - const startingWidgetEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); - if (eventsInIntendedState(startingWidgetEvents)) { - resolve(); - return; - } - - function onRoomStateEvents(ev: MatrixEvent) { - if (ev.getRoomId() !== roomId || ev.getType() !== "im.vector.modular.widgets") return; - - const currentWidgetEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); - - if (eventsInIntendedState(currentWidgetEvents)) { - matrixClient.removeListener(win.matrixcs.RoomStateEvent.Events, onRoomStateEvents); - resolve(); - } - } - - matrixClient.on(win.matrixcs.RoomStateEvent.Events, onRoomStateEvents); - }); -} - -describe("Widget PIP", () => { - let homeserver: HomeserverInstance; - let user: UserCredentials; - let bot: MatrixClient; - let demoWidgetUrl: string; - - function roomCreateAddWidgetPip(userRemove: "leave" | "kick" | "ban") { - cy.createRoom({ - name: ROOM_NAME, - invite: [bot.getUserId()], - }).then((roomId) => { - // sets bot to Admin and user to Moderator - cy.getClient() - .then((matrixClient) => { - return matrixClient.sendStateEvent(roomId, "m.room.power_levels", { - users: { - [user.userId]: 50, - [bot.getUserId()]: 100, - }, - }); - }) - .as("powerLevelsChanged"); - - // bot joins the room - cy.botJoinRoom(bot, roomId).as("botJoined"); - - // setup widget via state event - cy.getClient() - .then(async (matrixClient) => { - const content: IWidget = { - id: DEMO_WIDGET_ID, - creatorUserId: "somebody", - type: DEMO_WIDGET_TYPE, - name: DEMO_WIDGET_NAME, - url: demoWidgetUrl, - }; - await matrixClient.sendStateEvent(roomId, "im.vector.modular.widgets", content, DEMO_WIDGET_ID); - }) - .as("widgetEventSent"); - - // open the room - cy.viewRoomByName(ROOM_NAME); - - cy.all([ - cy.get("@powerLevelsChanged"), - cy.get("@botJoined"), - cy.get("@widgetEventSent"), - ]).then(() => { - cy.window().then(async (win) => { - // wait for widget state event - await waitForRoomWidget(win, DEMO_WIDGET_ID, roomId, true); - - // activate widget in pip mode - win.mxActiveWidgetStore.setWidgetPersistence(DEMO_WIDGET_ID, roomId, true); - - // checks that pip window is opened - cy.get(".mx_WidgetPip").should("exist"); - - // checks that widget is opened in pip - cy.accessIframe(`iframe[title="${DEMO_WIDGET_NAME}"]`).within({}, () => { - cy.get("#demo") - .should("exist") - .then(async () => { - const userId = user.userId; - if (userRemove == "leave") { - cy.getClient().then(async (matrixClient) => { - await matrixClient.leave(roomId); - }); - } else if (userRemove == "kick") { - await bot.kick(roomId, userId); - } else if (userRemove == "ban") { - await bot.ban(roomId, userId); - } - - // checks that pip window is closed - cy.get(".mx_WidgetPip").should("not.exist"); - }); - }); - }); - }); - }); - } - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Mike").then((_user) => { - user = _user; - }); - cy.getBot(homeserver, { displayName: "Bot", autoAcceptInvites: false }).then((_bot) => { - bot = _bot; - }); - }); - cy.serveHtmlFile(DEMO_WIDGET_HTML).then((url) => { - demoWidgetUrl = url; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should be closed on leave", () => { - roomCreateAddWidgetPip("leave"); - }); - - it("should be closed on kick", () => { - roomCreateAddWidgetPip("kick"); - }); - - it("should be closed on ban", () => { - roomCreateAddWidgetPip("ban"); - }); -}); diff --git a/cypress/fixtures/riot.png b/cypress/fixtures/riot.png deleted file mode 100644 index ee42954c7826..000000000000 Binary files a/cypress/fixtures/riot.png and /dev/null differ diff --git a/cypress/global.d.ts b/cypress/global.d.ts deleted file mode 100644 index f8caad1f89eb..000000000000 --- a/cypress/global.d.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import "../src/@types/global"; -import "../src/@types/svg"; -import "../src/@types/raw-loader"; -// eslint-disable-next-line no-restricted-imports -import "matrix-js-sdk/src/@types/global"; -import type { - MatrixClient, - ClientEvent, - MatrixScheduler, - MemoryCryptoStore, - MemoryStore, - Preset, - RoomStateEvent, - Visibility, - RoomMemberEvent, - ICreateClientOpts, -} from "matrix-js-sdk/src/matrix"; -import type { MatrixDispatcher } from "../src/dispatcher/dispatcher"; -import type PerformanceMonitor from "../src/performance"; -import type SettingsStore from "../src/settings/SettingsStore"; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface ApplicationWindow { - mxSettingsStore: typeof SettingsStore; - mxMatrixClientPeg: { - matrixClient?: MatrixClient; - }; - mxDispatcher: MatrixDispatcher; - mxPerformanceMonitor: PerformanceMonitor; - beforeReload?: boolean; // for detecting reloads - // Partial type for the matrix-js-sdk module, exported by browser-matrix - matrixcs: { - MatrixClient: typeof MatrixClient; - ClientEvent: typeof ClientEvent; - RoomMemberEvent: typeof RoomMemberEvent; - RoomStateEvent: typeof RoomStateEvent; - MatrixScheduler: typeof MatrixScheduler; - MemoryStore: typeof MemoryStore; - MemoryCryptoStore: typeof MemoryCryptoStore; - Visibility: typeof Visibility; - Preset: typeof Preset; - createClient(opts: ICreateClientOpts | string); - }; - } - } - - interface Window { - // to appease the MatrixDispatcher import - mxDispatcher: MatrixDispatcher; - // to appease the PerformanceMonitor import - mxPerformanceMonitor: PerformanceMonitor; - mxPerformanceEntryNames: any; - } -} - -export { MatrixClient }; diff --git a/cypress/plugins/dendritedocker/index.ts b/cypress/plugins/dendritedocker/index.ts deleted file mode 100644 index f89f898121ee..000000000000 --- a/cypress/plugins/dendritedocker/index.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import * as path from "path"; -import * as os from "os"; -import * as crypto from "crypto"; -import * as fse from "fs-extra"; - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; -import { getFreePort } from "../utils/port"; -import { dockerExec, dockerLogs, dockerRun, dockerStop } from "../docker"; -import { HomeserverConfig, HomeserverInstance } from "../utils/homeserver"; -import { StartHomeserverOpts } from "../../support/homeserver"; - -// A cypress plugins to add command to start & stop dendrites in -// docker with preset templates. - -const dendrites = new Map(); - -const dockerConfigDir = "/etc/dendrite/"; -const dendriteConfigFile = "dendrite.yaml"; - -function randB64Bytes(numBytes: number): string { - return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); -} - -async function cfgDirFromTemplate(template: string, dendriteImage: string): Promise { - template = "default"; - const templateDir = path.join(__dirname, "templates", template); - - const stats = await fse.stat(templateDir); - if (!stats?.isDirectory) { - throw new Error(`No such template: ${template}`); - } - const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), "react-sdk-dendritedocker-")); - - // copy the contents of the template dir, omitting homeserver.yaml as we'll template that - console.log(`Copy ${templateDir} -> ${tempDir}`); - await fse.copy(templateDir, tempDir, { filter: (f) => path.basename(f) !== dendriteConfigFile }); - - const registrationSecret = randB64Bytes(16); - - const port = await getFreePort(); - const baseUrl = `http://localhost:${port}`; - - // now copy homeserver.yaml, applying substitutions - console.log(`Gen ${path.join(templateDir, dendriteConfigFile)}`); - let hsYaml = await fse.readFile(path.join(templateDir, dendriteConfigFile), "utf8"); - hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret); - await fse.writeFile(path.join(tempDir, dendriteConfigFile), hsYaml); - - await dockerRun({ - image: dendriteImage, - params: ["--rm", "--entrypoint=", "-v", `${tempDir}:/mnt`], - containerName: `react-sdk-cypress-dendrite-keygen`, - cmd: ["/usr/bin/generate-keys", "-private-key", "/mnt/matrix_key.pem"], - }); - - return { - port, - baseUrl, - configDir: tempDir, - registrationSecret, - }; -} - -// Start a dendrite instance: the template must be the name of -// one of the templates in the cypress/plugins/dendritedocker/templates -// directory -async function dendriteStart(opts: StartHomeserverOpts): Promise { - return containerStart(opts.template, false); -} - -// Start a dendrite instance using pinecone routing: the template must be the name of -// one of the templates in the cypress/plugins/dendritedocker/templates -// directory -async function dendritePineconeStart(template: string): Promise { - return containerStart(template, true); -} - -async function containerStart(template: string, usePinecone: boolean): Promise { - let dendriteImage = "matrixdotorg/dendrite-monolith:main"; - let dendriteEntrypoint = "/usr/bin/dendrite"; - if (usePinecone) { - dendriteImage = "matrixdotorg/dendrite-demo-pinecone:main"; - dendriteEntrypoint = "/usr/bin/dendrite-demo-pinecone"; - } - const denCfg = await cfgDirFromTemplate(template, dendriteImage); - - console.log(`Starting dendrite with config dir ${denCfg.configDir}...`); - - const dendriteId = await dockerRun({ - image: dendriteImage, - params: [ - "--rm", - "-v", - `${denCfg.configDir}:` + dockerConfigDir, - "-p", - `${denCfg.port}:8008/tcp`, - "--entrypoint", - dendriteEntrypoint, - ], - containerName: `react-sdk-cypress-dendrite`, - cmd: ["--config", dockerConfigDir + dendriteConfigFile, "--really-enable-open-registration", "true", "run"], - }); - - console.log(`Started dendrite with id ${dendriteId} on port ${denCfg.port}.`); - - // Await Dendrite healthcheck - await dockerExec({ - containerId: dendriteId, - params: [ - "curl", - "--connect-timeout", - "30", - "--retry", - "30", - "--retry-delay", - "1", - "--retry-all-errors", - "--silent", - "http://localhost:8008/_matrix/client/versions", - ], - }); - - const dendrite: HomeserverInstance = { serverId: dendriteId, ...denCfg }; - dendrites.set(dendriteId, dendrite); - return dendrite; -} - -async function dendriteStop(id: string): Promise { - const denCfg = dendrites.get(id); - - if (!denCfg) throw new Error("Unknown dendrite ID"); - - const dendriteLogsPath = path.join("cypress", "dendritelogs", id); - await fse.ensureDir(dendriteLogsPath); - - await dockerLogs({ - containerId: id, - stdoutFile: path.join(dendriteLogsPath, "stdout.log"), - stderrFile: path.join(dendriteLogsPath, "stderr.log"), - }); - - await dockerStop({ - containerId: id, - }); - - await fse.remove(denCfg.configDir); - - dendrites.delete(id); - - console.log(`Stopped dendrite id ${id}.`); - // cypress deliberately fails if you return 'undefined', so - // return null to signal all is well, and we've handled the task. - return null; -} - -async function dendritePineconeStop(id: string): Promise { - return dendriteStop(id); -} - -/** - * @type {Cypress.PluginConfig} - */ -export function dendriteDocker(on: PluginEvents, config: PluginConfigOptions) { - on("task", { - dendriteStart, - dendriteStop, - dendritePineconeStart, - dendritePineconeStop, - }); - - on("after:spec", async (spec) => { - // Cleans up any remaining dendrite instances after a spec run - // This is on the theory that we should avoid re-using dendrite - // instances between spec runs: they should be cheap enough to - // start that we can have a separate one for each spec run or even - // test. If we accidentally re-use dendrites, we could inadvertently - // make our tests depend on each other. - for (const denId of dendrites.keys()) { - console.warn(`Cleaning up dendrite ID ${denId} after ${spec.name}`); - await dendriteStop(denId); - } - }); - - on("before:run", async () => { - // tidy up old dendrite log files before each run - await fse.emptyDir(path.join("cypress", "dendritelogs")); - }); -} diff --git a/cypress/plugins/dendritedocker/templates/default/dendrite.yaml b/cypress/plugins/dendritedocker/templates/default/dendrite.yaml deleted file mode 100644 index 634cebbc8768..000000000000 --- a/cypress/plugins/dendritedocker/templates/default/dendrite.yaml +++ /dev/null @@ -1,378 +0,0 @@ -# This is the Dendrite configuration file. -# -# The configuration is split up into sections - each Dendrite component has a -# configuration section, in addition to the "global" section which applies to -# all components. - -# The version of the configuration file. -version: 2 - -# Global Matrix configuration. This configuration applies to all components. -global: - # The domain name of this homeserver. - server_name: localhost - - # The path to the signing private key file, used to sign requests and events. - # Note that this is NOT the same private key as used for TLS! To generate a - # signing key, use "./bin/generate-keys --private-key matrix_key.pem". - private_key: matrix_key.pem - - # The paths and expiry timestamps (as a UNIX timestamp in millisecond precision) - # to old signing keys that were formerly in use on this domain name. These - # keys will not be used for federation request or event signing, but will be - # provided to any other homeserver that asks when trying to verify old events. - old_private_keys: - # If the old private key file is available: - # - private_key: old_matrix_key.pem - # expired_at: 1601024554498 - # If only the public key (in base64 format) and key ID are known: - # - public_key: mn59Kxfdq9VziYHSBzI7+EDPDcBS2Xl7jeUdiiQcOnM= - # key_id: ed25519:mykeyid - # expired_at: 1601024554498 - - # How long a remote server can cache our server signing key before requesting it - # again. Increasing this number will reduce the number of requests made by other - # servers for our key but increases the period that a compromised key will be - # considered valid by other homeservers. - key_validity_period: 168h0m0s - - # Global database connection pool, for PostgreSQL monolith deployments only. If - # this section is populated then you can omit the "database" blocks in all other - # sections. For polylith deployments, or monolith deployments using SQLite databases, - # you must configure the "database" block for each component instead. - # database: - # connection_string: postgresql://username:password@hostname/dendrite?sslmode=disable - # max_open_conns: 90 - # max_idle_conns: 5 - # conn_max_lifetime: -1 - - # Configuration for in-memory caches. Caches can often improve performance by - # keeping frequently accessed items (like events, identifiers etc.) in memory - # rather than having to read them from the database. - cache: - # The estimated maximum size for the global cache in bytes, or in terabytes, - # gigabytes, megabytes or kilobytes when the appropriate 'tb', 'gb', 'mb' or - # 'kb' suffix is specified. Note that this is not a hard limit, nor is it a - # memory limit for the entire process. A cache that is too small may ultimately - # provide little or no benefit. - max_size_estimated: 1gb - - # The maximum amount of time that a cache entry can live for in memory before - # it will be evicted and/or refreshed from the database. Lower values result in - # easier admission of new cache entries but may also increase database load in - # comparison to higher values, so adjust conservatively. Higher values may make - # it harder for new items to make it into the cache, e.g. if new rooms suddenly - # become popular. - max_age: 1h - - # The server name to delegate server-server communications to, with optional port - # e.g. localhost:443 - well_known_server_name: "" - - # The server name to delegate client-server communications to, with optional port - # e.g. localhost:443 - well_known_client_name: "" - - # Lists of domains that the server will trust as identity servers to verify third - # party identifiers such as phone numbers and email addresses. - trusted_third_party_id_servers: - - matrix.org - - vector.im - - # Disables federation. Dendrite will not be able to communicate with other servers - # in the Matrix federation and the federation API will not be exposed. - disable_federation: false - - # Configures the handling of presence events. Inbound controls whether we receive - # presence events from other servers, outbound controls whether we send presence - # events for our local users to other servers. - presence: - enable_inbound: false - enable_outbound: false - - # Configures phone-home statistics reporting. These statistics contain the server - # name, number of active users and some information on your deployment config. - # We use this information to understand how Dendrite is being used in the wild. - report_stats: - enabled: false - endpoint: https://matrix.org/report-usage-stats/push - - # Server notices allows server admins to send messages to all users on the server. - server_notices: - enabled: false - # The local part, display name and avatar URL (as a mxc:// URL) for the user that - # will send the server notices. These are visible to all users on the deployment. - local_part: "_server" - display_name: "Server Alerts" - avatar_url: "" - # The room name to be used when sending server notices. This room name will - # appear in user clients. - room_name: "Server Alerts" - - # Configuration for NATS JetStream - jetstream: - # A list of NATS Server addresses to connect to. If none are specified, an - # internal NATS server will be started automatically when running Dendrite in - # monolith mode. For polylith deployments, it is required to specify the address - # of at least one NATS Server node. - addresses: - # - localhost:4222 - - # Disable the validation of TLS certificates of NATS. This is - # not recommended in production since it may allow NATS traffic - # to be sent to an insecure endpoint. - disable_tls_validation: false - - # Persistent directory to store JetStream streams in. This directory should be - # preserved across Dendrite restarts. - storage_path: ./ - - # The prefix to use for stream names for this homeserver - really only useful - # if you are running more than one Dendrite server on the same NATS deployment. - topic_prefix: Dendrite - - # Configuration for Prometheus metric collection. - metrics: - enabled: false - basic_auth: - username: metrics - password: metrics - - # Optional DNS cache. The DNS cache may reduce the load on DNS servers if there - # is no local caching resolver available for use. - dns_cache: - enabled: false - cache_size: 256 - cache_lifetime: "5m" # 5 minutes; https://pkg.go.dev/time@master#ParseDuration - -# Configuration for the Appservice API. -app_service_api: - # Disable the validation of TLS certificates of appservices. This is - # not recommended in production since it may allow appservice traffic - # to be sent to an insecure endpoint. - disable_tls_validation: false - - # Appservice configuration files to load into this homeserver. - config_files: - # - /path/to/appservice_registration.yaml - -# Configuration for the Client API. -client_api: - # Prevents new users from being able to register on this homeserver, except when - # using the registration shared secret below. - registration_disabled: false - - # Prevents new guest accounts from being created. Guest registration is also - # disabled implicitly by setting 'registration_disabled' above. - guests_disabled: true - - # If set, allows registration by anyone who knows the shared secret, regardless - # of whether registration is otherwise disabled. - registration_shared_secret: "{{REGISTRATION_SECRET}}" - - # Whether to require reCAPTCHA for registration. If you have enabled registration - # then this is HIGHLY RECOMMENDED to reduce the risk of your homeserver being used - # for coordinated spam attacks. - enable_registration_captcha: false - - # Settings for ReCAPTCHA. - recaptcha_public_key: "" - recaptcha_private_key: "" - recaptcha_bypass_secret: "" - - # To use hcaptcha.com instead of ReCAPTCHA, set the following parameters, otherwise just keep them empty. - # recaptcha_siteverify_api: "https://hcaptcha.com/siteverify" - # recaptcha_api_js_url: "https://js.hcaptcha.com/1/api.js" - # recaptcha_form_field: "h-captcha-response" - # recaptcha_sitekey_class: "h-captcha" - - # TURN server information that this homeserver should send to clients. - turn: - turn_user_lifetime: "5m" - turn_uris: - # - turn:turn.server.org?transport=udp - # - turn:turn.server.org?transport=tcp - turn_shared_secret: "" - # If your TURN server requires static credentials, then you will need to enter - # them here instead of supplying a shared secret. Note that these credentials - # will be visible to clients! - # turn_username: "" - # turn_password: "" - - # Settings for rate-limited endpoints. Rate limiting kicks in after the threshold - # number of "slots" have been taken by requests from a specific host. Each "slot" - # will be released after the cooloff time in milliseconds. Server administrators - # and appservice users are exempt from rate limiting by default. - rate_limiting: - enabled: true - threshold: 20 - cooloff_ms: 500 - exempt_user_ids: - # - "@user:domain.com" - -# Configuration for the Federation API. -federation_api: - # How many times we will try to resend a failed transaction to a specific server. The - # backoff is 2**x seconds, so 1 = 2 seconds, 2 = 4 seconds, 3 = 8 seconds etc. Once - # the max retries are exceeded, Dendrite will no longer try to send transactions to - # that server until it comes back to life and connects to us again. - send_max_retries: 16 - - # Disable the validation of TLS certificates of remote federated homeservers. Do not - # enable this option in production as it presents a security risk! - disable_tls_validation: false - - # Disable HTTP keepalives, which also prevents connection reuse. Dendrite will typically - # keep HTTP connections open to remote hosts for 5 minutes as they can be reused much - # more quickly than opening new connections each time. Disabling keepalives will close - # HTTP connections immediately after a successful request but may result in more CPU and - # memory being used on TLS handshakes for each new connection instead. - disable_http_keepalives: false - - # Perspective keyservers to use as a backup when direct key fetches fail. This may - # be required to satisfy key requests for servers that are no longer online when - # joining some rooms. - key_perspectives: - - server_name: matrix.org - keys: - - key_id: ed25519:auto - public_key: Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw - - key_id: ed25519:a_RXGa - public_key: l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ - - # This option will control whether Dendrite will prefer to look up keys directly - # or whether it should try perspective servers first, using direct fetches as a - # last resort. - prefer_direct_fetch: false - - database: - connection_string: file:dendrite-federationapi.db - -# Configuration for the Media API. -media_api: - # Storage path for uploaded media. May be relative or absolute. - base_path: ./media_store - - # The maximum allowed file size (in bytes) for media uploads to this homeserver - # (0 = unlimited). If using a reverse proxy, ensure it allows requests at least - #this large (e.g. the client_max_body_size setting in nginx). - max_file_size_bytes: 10485760 - - # Whether to dynamically generate thumbnails if needed. - dynamic_thumbnails: false - - # The maximum number of simultaneous thumbnail generators to run. - max_thumbnail_generators: 10 - - # A list of thumbnail sizes to be generated for media content. - thumbnail_sizes: - - width: 32 - height: 32 - method: crop - - width: 96 - height: 96 - method: crop - - width: 640 - height: 480 - method: scale - - database: - connection_string: file:dendrite-mediaapi.db - -# Configuration for enabling experimental MSCs on this homeserver. -mscs: - mscs: - # - msc2836 # (Threading, see https://github.com/matrix-org/matrix-doc/pull/2836) - # - msc2946 # (Spaces Summary, see https://github.com/matrix-org/matrix-doc/pull/2946) - - database: - connection_string: file:dendrite-msc.db - -# Configuration for the Sync API. -sync_api: - # This option controls which HTTP header to inspect to find the real remote IP - # address of the client. This is likely required if Dendrite is running behind - # a reverse proxy server. - # real_ip_header: X-Real-IP - - # Configuration for the full-text search engine. - search: - # Whether or not search is enabled. - enabled: false - - # The path where the search index will be created in. - index_path: "./searchindex" - - # The language most likely to be used on the server - used when indexing, to - # ensure the returned results match expectations. A full list of possible languages - # can be found at https://github.com/blevesearch/bleve/tree/master/analysis/lang - language: "en" - - database: - connection_string: file:dendrite-syncapi.db - -# Configuration for the User API. -user_api: - # The cost when hashing passwords on registration/login. Default: 10. Min: 4, Max: 31 - # See https://pkg.go.dev/golang.org/x/crypto/bcrypt for more information. - # Setting this lower makes registration/login consume less CPU resources at the cost - # of security should the database be compromised. Setting this higher makes registration/login - # consume more CPU resources but makes it harder to brute force password hashes. This value - # can be lowered if performing tests or on embedded Dendrite instances (e.g WASM builds). - bcrypt_cost: 10 - - # The length of time that a token issued for a relying party from - # /_matrix/client/r0/user/{userId}/openid/request_token endpoint - # is considered to be valid in milliseconds. - # The default lifetime is 3600000ms (60 minutes). - # openid_token_lifetime_ms: 3600000 - - # Users who register on this homeserver will automatically be joined to the rooms listed under "auto_join_rooms" option. - # By default, any room aliases included in this list will be created as a publicly joinable room - # when the first user registers for the homeserver. If the room already exists, - # make certain it is a publicly joinable room, i.e. the join rule of the room must be set to 'public'. - # As Spaces are just rooms under the hood, Space aliases may also be used. - auto_join_rooms: - # - "#main:matrix.org" - - account_database: - connection_string: file:dendrite-userapi.db - -room_server: - database: - connection_string: file:dendrite-roomserverapi.db - -key_server: - database: - connection_string: file:dendrite-keyserverapi.db - -relay_api: - database: - connection_string: file:dendrite-relayapi.db - -# Configuration for Opentracing. -# See https://github.com/matrix-org/dendrite/tree/master/docs/tracing for information on -# how this works and how to set it up. -tracing: - enabled: false - jaeger: - serviceName: "" - disabled: false - rpc_metrics: false - tags: [] - sampler: null - reporter: null - headers: null - baggage_restrictions: null - throttler: null - -# Logging configuration. The "std" logging type controls the logs being sent to -# stdout. The "file" logging type controls logs being written to a log folder on -# the disk. Supported log levels are "debug", "info", "warn", "error". -logging: - - type: std - level: debug - - type: file - level: debug - params: - path: ./logs diff --git a/cypress/plugins/docker/index.ts b/cypress/plugins/docker/index.ts deleted file mode 100644 index e05b4a3c076e..000000000000 --- a/cypress/plugins/docker/index.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import * as os from "os"; -import * as crypto from "crypto"; -import * as childProcess from "child_process"; -import * as fse from "fs-extra"; - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; - -// A cypress plugin to run docker commands - -export async function dockerRun(opts: { - image: string; - containerName: string; - params?: string[]; - cmd?: string[]; -}): Promise { - const userInfo = os.userInfo(); - const params = opts.params ?? []; - - if (params?.includes("-v") && userInfo.uid >= 0) { - // Run the docker container as our uid:gid to prevent problems with permissions. - if (await isPodman()) { - // Note: this setup is for podman rootless containers. - - // In podman, run as root in the container, which maps to the current - // user on the host. This is probably the default since Synapse's - // Dockerfile doesn't specify, but we're being explicit here - // because it's important for the permissions to work. - params.push("-u", "0:0"); - - // Tell Synapse not to switch UID - params.push("-e", "UID=0"); - params.push("-e", "GID=0"); - } else { - params.push("-u", `${userInfo.uid}:${userInfo.gid}`); - } - } - - const args = [ - "run", - "--name", - `${opts.containerName}-${crypto.randomBytes(4).toString("hex")}`, - "-d", - "--rm", - ...params, - opts.image, - ]; - - if (opts.cmd) args.push(...opts.cmd); - - return new Promise((resolve, reject) => { - childProcess.execFile("docker", args, (err, stdout) => { - if (err) reject(err); - resolve(stdout.trim()); - }); - }); -} - -export function dockerExec(args: { containerId: string; params: string[] }): Promise { - return new Promise((resolve, reject) => { - childProcess.execFile( - "docker", - ["exec", args.containerId, ...args.params], - { encoding: "utf8" }, - (err, stdout, stderr) => { - if (err) { - console.log(stdout); - console.log(stderr); - reject(err); - return; - } - resolve(); - }, - ); - }); -} - -export async function dockerLogs(args: { - containerId: string; - stdoutFile?: string; - stderrFile?: string; -}): Promise { - const stdoutFile = args.stdoutFile ? await fse.open(args.stdoutFile, "w") : "ignore"; - const stderrFile = args.stderrFile ? await fse.open(args.stderrFile, "w") : "ignore"; - - await new Promise((resolve) => { - childProcess - .spawn("docker", ["logs", args.containerId], { - stdio: ["ignore", stdoutFile, stderrFile], - }) - .once("close", resolve); - }); - - if (args.stdoutFile) await fse.close(stdoutFile); - if (args.stderrFile) await fse.close(stderrFile); -} - -export function dockerStop(args: { containerId: string }): Promise { - return new Promise((resolve, reject) => { - childProcess.execFile("docker", ["stop", args.containerId], (err) => { - if (err) reject(err); - resolve(); - }); - }); -} - -export function dockerRm(args: { containerId: string }): Promise { - return new Promise((resolve, reject) => { - childProcess.execFile("docker", ["rm", args.containerId], (err) => { - if (err) reject(err); - resolve(); - }); - }); -} - -export function dockerIp(args: { containerId: string }): Promise { - return new Promise((resolve, reject) => { - childProcess.execFile( - "docker", - ["inspect", "-f", "{{ .NetworkSettings.IPAddress }}", args.containerId], - (err, stdout) => { - if (err) reject(err); - else resolve(stdout.trim()); - }, - ); - }); -} - -/** - * Detects whether the docker command is actually podman. - * To do this, it looks for "podman" in the output of "docker --help". - */ -export function isPodman(): Promise { - return new Promise((resolve, reject) => { - childProcess.execFile("docker", ["--help"], (err, stdout) => { - if (err) reject(err); - else resolve(stdout.toLowerCase().includes("podman")); - }); - }); -} - -/** - * Supply the right hostname to use to talk to the host machine. On Docker this - * is "host.docker.internal" and on Podman this is "host.containers.internal". - */ -export async function hostContainerName() { - return (await isPodman()) ? "host.containers.internal" : "host.docker.internal"; -} - -/** - * @type {Cypress.PluginConfig} - */ -export function docker(on: PluginEvents, config: PluginConfigOptions) { - on("task", { - dockerRun, - dockerExec, - dockerLogs, - dockerStop, - dockerRm, - dockerIp, - }); -} diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts deleted file mode 100644 index a0bd7a5c7fad..000000000000 --- a/cypress/plugins/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// -import installLogsPrinter from "cypress-terminal-report/src/installLogsPrinter"; -import { initPlugins } from "cypress-plugin-init"; - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; -import { synapseDocker } from "./synapsedocker"; -import { dendriteDocker } from "./dendritedocker"; -import { slidingSyncProxyDocker } from "./sliding-sync"; -import { webserver } from "./webserver"; -import { docker } from "./docker"; -import { log } from "./log"; - -/** - * @type {Cypress.PluginConfig} - */ -export default function (on: PluginEvents, config: PluginConfigOptions) { - initPlugins(on, [docker, synapseDocker, dendriteDocker, slidingSyncProxyDocker, webserver, log], config); - installLogsPrinter(on, { - printLogsToConsole: "never", - - // write logs to cypress/results/cypresslogs/.txt - outputRoot: "cypress/results", - outputTarget: { - "cypresslogs|txt": "txt", - }, - - // strip 'cypress/e2e' from log filenames - specRoot: "cypress/e2e", - }); -} diff --git a/cypress/plugins/log.ts b/cypress/plugins/log.ts deleted file mode 100644 index 4b16c9b8cdb7..000000000000 --- a/cypress/plugins/log.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; - -export function log(on: PluginEvents, config: PluginConfigOptions) { - on("task", { - log(message: string) { - console.log(message); - - return null; - }, - table(message: string) { - console.table(message); - - return null; - }, - }); -} diff --git a/cypress/plugins/mailhog/index.ts b/cypress/plugins/mailhog/index.ts deleted file mode 100644 index a156e9398186..000000000000 --- a/cypress/plugins/mailhog/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; -import { getFreePort } from "../utils/port"; -import { dockerIp, dockerRun, dockerStop } from "../docker"; - -// A cypress plugins to add command to manage an instance of Mailhog in Docker - -export interface Instance { - host: string; - smtpPort: number; - httpPort: number; - containerId: string; -} - -const instances = new Map(); - -// Start a synapse instance: the template must be the name of -// one of the templates in the cypress/plugins/synapsedocker/templates -// directory -async function mailhogStart(): Promise { - const smtpPort = await getFreePort(); - const httpPort = await getFreePort(); - - console.log(`Starting mailhog...`); - - const containerId = await dockerRun({ - image: "mailhog/mailhog:latest", - containerName: `react-sdk-cypress-mailhog`, - params: ["--rm", "-p", `${smtpPort}:1025/tcp`, "-p", `${httpPort}:8025/tcp`], - }); - - console.log(`Started mailhog on ports smtp=${smtpPort} http=${httpPort}.`); - - const host = await dockerIp({ containerId }); - const instance: Instance = { smtpPort, httpPort, containerId, host }; - instances.set(containerId, instance); - return instance; -} - -async function mailhogStop(id: string): Promise { - const synCfg = instances.get(id); - - if (!synCfg) throw new Error("Unknown mailhog ID"); - - await dockerStop({ - containerId: id, - }); - - instances.delete(id); - - console.log(`Stopped mailhog id ${id}.`); - // cypress deliberately fails if you return 'undefined', so - // return null to signal all is well, and we've handled the task. - return null; -} - -/** - * @type {Cypress.PluginConfig} - */ -export function mailhogDocker(on: PluginEvents, config: PluginConfigOptions) { - on("task", { - mailhogStart, - mailhogStop, - }); - - on("after:spec", async (spec) => { - // Cleans up any remaining instances after a spec run - for (const synId of instances.keys()) { - console.warn(`Cleaning up synapse ID ${synId} after ${spec.name}`); - await mailhogStop(synId); - } - }); -} diff --git a/cypress/plugins/sliding-sync/index.ts b/cypress/plugins/sliding-sync/index.ts deleted file mode 100644 index ab39c7a42b7a..000000000000 --- a/cypress/plugins/sliding-sync/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; -import { dockerExec, dockerIp, dockerRun, dockerStop } from "../docker"; -import { getFreePort } from "../utils/port"; -import { HomeserverInstance } from "../utils/homeserver"; - -// A cypress plugin to add command to start & stop https://github.com/matrix-org/sliding-sync -// SLIDING_SYNC_PROXY_TAG env used as the docker tag to use for `ghcr.io/matrix-org/sliding-sync` image. - -export interface ProxyInstance { - containerId: string; - postgresId: string; - port: number; -} - -const instances = new Map(); - -const PG_PASSWORD = "p4S5w0rD"; - -async function proxyStart(dockerTag: string, homeserver: HomeserverInstance): Promise { - console.log(new Date(), "Starting sliding sync proxy..."); - - const postgresId = await dockerRun({ - image: "postgres", - containerName: "react-sdk-cypress-sliding-sync-postgres", - params: ["--rm", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`], - }); - - const postgresIp = await dockerIp({ containerId: postgresId }); - const homeserverIp = await dockerIp({ containerId: homeserver.serverId }); - console.log(new Date(), "postgres container up"); - - const waitTimeMillis = 30000; - const startTime = new Date().getTime(); - let lastErr: Error; - while (new Date().getTime() - startTime < waitTimeMillis) { - try { - await dockerExec({ - containerId: postgresId, - params: ["pg_isready", "-U", "postgres"], - }); - lastErr = null; - break; - } catch (err) { - console.log("pg_isready: failed"); - lastErr = err; - } - } - if (lastErr) { - console.log("rethrowing"); - throw lastErr; - } - - const port = await getFreePort(); - console.log(new Date(), "starting proxy container...", dockerTag); - const containerId = await dockerRun({ - image: "ghcr.io/matrix-org/sliding-sync:" + dockerTag, - containerName: "react-sdk-cypress-sliding-sync-proxy", - params: [ - "--rm", - "-p", - `${port}:8008/tcp`, - "-e", - "SYNCV3_SECRET=bwahahaha", - "-e", - `SYNCV3_SERVER=http://${homeserverIp}:8008`, - "-e", - `SYNCV3_DB=user=postgres dbname=postgres password=${PG_PASSWORD} host=${postgresIp} sslmode=disable`, - ], - }); - console.log(new Date(), "started!"); - - const instance: ProxyInstance = { containerId, postgresId, port }; - instances.set(containerId, instance); - return instance; -} - -async function proxyStop(instance: ProxyInstance): Promise { - await dockerStop({ - containerId: instance.containerId, - }); - await dockerStop({ - containerId: instance.postgresId, - }); - - instances.delete(instance.containerId); - - console.log(new Date(), "Stopped sliding sync proxy."); - // cypress deliberately fails if you return 'undefined', so - // return null to signal all is well, and we've handled the task. - return null; -} - -/** - * @type {Cypress.PluginConfig} - */ -export function slidingSyncProxyDocker(on: PluginEvents, config: PluginConfigOptions) { - const dockerTag = config.env["SLIDING_SYNC_PROXY_TAG"]; - - on("task", { - proxyStart: proxyStart.bind(null, dockerTag), - proxyStop, - }); - - on("after:spec", async (spec) => { - for (const instance of instances.values()) { - console.warn(`Cleaning up proxy on port ${instance.port} after ${spec.name}`); - await proxyStop(instance); - } - }); -} diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts deleted file mode 100644 index 7c278610cc10..000000000000 --- a/cypress/plugins/synapsedocker/index.ts +++ /dev/null @@ -1,218 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import * as path from "path"; -import * as os from "os"; -import * as crypto from "crypto"; -import * as fse from "fs-extra"; - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; -import { getFreePort } from "../utils/port"; -import { dockerExec, dockerLogs, dockerRun, dockerStop, hostContainerName, isPodman } from "../docker"; -import { HomeserverConfig, HomeserverInstance } from "../utils/homeserver"; -import { StartHomeserverOpts } from "../../support/homeserver"; - -// A cypress plugins to add command to start & stop synapses in -// docker with preset templates. - -const synapses = new Map(); - -function randB64Bytes(numBytes: number): string { - return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); -} - -async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise { - const templateDir = path.join(__dirname, "templates", opts.template); - - const stats = await fse.stat(templateDir); - if (!stats?.isDirectory) { - throw new Error(`No such template: ${opts.template}`); - } - const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), "react-sdk-synapsedocker-")); - - // copy the contents of the template dir, omitting homeserver.yaml as we'll template that - console.log(`Copy ${templateDir} -> ${tempDir}`); - await fse.copy(templateDir, tempDir, { filter: (f) => path.basename(f) !== "homeserver.yaml" }); - - const registrationSecret = randB64Bytes(16); - const macaroonSecret = randB64Bytes(16); - const formSecret = randB64Bytes(16); - - const port = await getFreePort(); - const baseUrl = `http://localhost:${port}`; - - // now copy homeserver.yaml, applying substitutions - const templateHomeserver = path.join(templateDir, "homeserver.yaml"); - const outputHomeserver = path.join(tempDir, "homeserver.yaml"); - console.log(`Gen ${templateHomeserver} -> ${outputHomeserver}`); - let hsYaml = await fse.readFile(templateHomeserver, "utf8"); - hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret); - hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret); - hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret); - hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl); - hsYaml = hsYaml.replace(/{{OAUTH_SERVER_PORT}}/g, opts.oAuthServerPort?.toString()); - hsYaml = hsYaml.replace(/{{HOST_DOCKER_INTERNAL}}/g, await hostContainerName()); - if (opts.variables) { - let fetchedHostContainer = null; - for (const key in opts.variables) { - let value = String(opts.variables[key]); - - if (value === "{{HOST_DOCKER_INTERNAL}}") { - if (!fetchedHostContainer) { - fetchedHostContainer = await hostContainerName(); - } - value = fetchedHostContainer; - } - - hsYaml = hsYaml.replace(new RegExp("%" + key + "%", "g"), value); - } - } - - await fse.writeFile(outputHomeserver, hsYaml); - - // now generate a signing key (we could use synapse's config generation for - // this, or we could just do this...) - // NB. This assumes the homeserver.yaml specifies the key in this location - const signingKey = randB64Bytes(32); - const outputSigningKey = path.join(tempDir, "localhost.signing.key"); - console.log(`Gen -> ${outputSigningKey}`); - await fse.writeFile(outputSigningKey, `ed25519 x ${signingKey}`); - - return { - port, - baseUrl, - configDir: tempDir, - registrationSecret, - }; -} - -/** - * Start a synapse instance: the template must be the name of - * one of the templates in the cypress/plugins/synapsedocker/templates - * directory. - * - * Any value in opts.variables that is set to `{{HOST_DOCKER_INTERNAL}}' - * will be replaced with 'host.docker.internal' (if we are on Docker) or - * 'host.containers.interal' if we are on Podman. - */ -async function synapseStart(opts: StartHomeserverOpts): Promise { - const synCfg = await cfgDirFromTemplate(opts); - - console.log(`Starting synapse with config dir ${synCfg.configDir}...`); - - const dockerSynapseParams = ["--rm", "-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`]; - - if (await isPodman()) { - // Make host.containers.internal work to allow Synapse to talk to the - // test OIDC server. - dockerSynapseParams.push("--network"); - dockerSynapseParams.push("slirp4netns:allow_host_loopback=true"); - } else { - // Make host.docker.internal work to allow Synapse to talk to the test - // OIDC server. - dockerSynapseParams.push("--add-host"); - dockerSynapseParams.push("host.docker.internal:host-gateway"); - } - - const synapseId = await dockerRun({ - image: "matrixdotorg/synapse:develop", - containerName: `react-sdk-cypress-synapse`, - params: dockerSynapseParams, - cmd: ["run"], - }); - - console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); - - // Await Synapse healthcheck - await dockerExec({ - containerId: synapseId, - params: [ - "curl", - "--connect-timeout", - "30", - "--retry", - "30", - "--retry-delay", - "1", - "--retry-all-errors", - "--silent", - "http://localhost:8008/health", - ], - }); - - const synapse: HomeserverInstance = { serverId: synapseId, ...synCfg }; - synapses.set(synapseId, synapse); - return synapse; -} - -async function synapseStop(id: string): Promise { - const synCfg = synapses.get(id); - - if (!synCfg) throw new Error("Unknown synapse ID"); - - const synapseLogsPath = path.join("cypress", "synapselogs", id); - await fse.ensureDir(synapseLogsPath); - - await dockerLogs({ - containerId: id, - stdoutFile: path.join(synapseLogsPath, "stdout.log"), - stderrFile: path.join(synapseLogsPath, "stderr.log"), - }); - - await dockerStop({ - containerId: id, - }); - - await fse.remove(synCfg.configDir); - - synapses.delete(id); - - console.log(`Stopped synapse id ${id}.`); - // cypress deliberately fails if you return 'undefined', so - // return null to signal all is well, and we've handled the task. - return null; -} - -/** - * @type {Cypress.PluginConfig} - */ -export function synapseDocker(on: PluginEvents, config: PluginConfigOptions) { - on("task", { - synapseStart, - synapseStop, - }); - - on("after:spec", async (spec) => { - // Cleans up any remaining synapse instances after a spec run - // This is on the theory that we should avoid re-using synapse - // instances between spec runs: they should be cheap enough to - // start that we can have a separate one for each spec run or even - // test. If we accidentally re-use synapses, we could inadvertently - // make our tests depend on each other. - for (const synId of synapses.keys()) { - console.warn(`Cleaning up synapse ID ${synId} after ${spec.name}`); - await synapseStop(synId); - } - }); - - on("before:run", async () => { - // tidy up old synapse log files before each run - await fse.emptyDir(path.join("cypress", "synapselogs")); - }); -} diff --git a/cypress/plugins/synapsedocker/templates/COPYME/README.md b/cypress/plugins/synapsedocker/templates/COPYME/README.md deleted file mode 100644 index df1ed89e6e45..000000000000 --- a/cypress/plugins/synapsedocker/templates/COPYME/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Meta-template for synapse templates - -To make another template, you can copy this directory diff --git a/cypress/plugins/synapsedocker/templates/COPYME/homeserver.yaml b/cypress/plugins/synapsedocker/templates/COPYME/homeserver.yaml deleted file mode 100644 index cb58dc866156..000000000000 --- a/cypress/plugins/synapsedocker/templates/COPYME/homeserver.yaml +++ /dev/null @@ -1,72 +0,0 @@ -server_name: "localhost" -pid_file: /data/homeserver.pid -# XXX: This won't actually be right: it lets docker allocate an ephemeral port, -# so we have a chicken-and-egg problem -public_baseurl: http://localhost:8008/ -# Listener is always port 8008 (configured in the container) -listeners: - - port: 8008 - tls: false - bind_addresses: ["::"] - type: http - x_forwarded: true - - resources: - - names: [client, federation, consent] - compress: false - -# An sqlite in-memory database is fast & automatically wipes each time -database: - name: "sqlite3" - args: - database: ":memory:" - -# Needs to be configured to log to the console like a good docker process -log_config: "/data/log.config" - -rc_messages_per_second: 10000 -rc_message_burst_count: 10000 -rc_registration: - per_second: 10000 - burst_count: 10000 - -rc_login: - address: - per_second: 10000 - burst_count: 10000 - account: - per_second: 10000 - burst_count: 10000 - failed_attempts: - per_second: 10000 - burst_count: 10000 - -media_store_path: "/data/media_store" -uploads_path: "/data/uploads" -enable_registration: true -enable_registration_without_verification: true -disable_msisdn_registration: false -# These placeholders will be be replaced with values generated at start -registration_shared_secret: "{{REGISTRATION_SECRET}}" -report_stats: false -macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" -form_secret: "{{FORM_SECRET}}" -# Signing key must be here: it will be generated to this file -signing_key_path: "/data/localhost.signing.key" -email: - enable_notifs: false - smtp_host: "localhost" - smtp_port: 25 - smtp_user: "exampleusername" - smtp_pass: "examplepassword" - require_transport_security: False - notif_from: "Your Friendly %(app)s homeserver " - app_name: Matrix - notif_template_html: notif_mail.html - notif_template_text: notif_mail.txt - notif_for_new_users: True - client_base_url: "http://localhost/element" - -trusted_key_servers: - - server_name: "matrix.org" -suppress_key_server_warning: true diff --git a/cypress/plugins/synapsedocker/templates/COPYME/log.config b/cypress/plugins/synapsedocker/templates/COPYME/log.config deleted file mode 100644 index ac232762da3f..000000000000 --- a/cypress/plugins/synapsedocker/templates/COPYME/log.config +++ /dev/null @@ -1,50 +0,0 @@ -# Log configuration for Synapse. -# -# This is a YAML file containing a standard Python logging configuration -# dictionary. See [1] for details on the valid settings. -# -# Synapse also supports structured logging for machine readable logs which can -# be ingested by ELK stacks. See [2] for details. -# -# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema -# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html - -version: 1 - -formatters: - precise: - format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' - -handlers: - # A handler that writes logs to stderr. Unused by default, but can be used - # instead of "buffer" and "file" in the logger handlers. - console: - class: logging.StreamHandler - formatter: precise - -loggers: - synapse.storage.SQL: - # beware: increasing this to DEBUG will make synapse log sensitive - # information such as access tokens. - level: INFO - - twisted: - # We send the twisted logging directly to the file handler, - # to work around https://github.com/matrix-org/synapse/issues/3471 - # when using "buffer" logger. Use "console" to log to stderr instead. - handlers: [console] - propagate: false - -root: - level: INFO - - # Write logs to the `buffer` handler, which will buffer them together in memory, - # then write them to a file. - # - # Replace "buffer" with "console" to log to stderr instead. (Note that you'll - # also need to update the configuration for the `twisted` logger above, in - # this case.) - # - handlers: [console] - -disable_existing_loggers: false diff --git a/cypress/plugins/synapsedocker/templates/consent/README.md b/cypress/plugins/synapsedocker/templates/consent/README.md deleted file mode 100644 index 713e55f9d517..000000000000 --- a/cypress/plugins/synapsedocker/templates/consent/README.md +++ /dev/null @@ -1 +0,0 @@ -A synapse configured with user privacy consent enabled diff --git a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml b/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml deleted file mode 100644 index d3a4fa520cac..000000000000 --- a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml +++ /dev/null @@ -1,84 +0,0 @@ -server_name: "localhost" -pid_file: /data/homeserver.pid -public_baseurl: "{{PUBLIC_BASEURL}}" -listeners: - - port: 8008 - tls: false - bind_addresses: ["::"] - type: http - x_forwarded: true - - resources: - - names: [client, federation, consent] - compress: false - -database: - name: "sqlite3" - args: - database: ":memory:" - -log_config: "/data/log.config" - -rc_messages_per_second: 10000 -rc_message_burst_count: 10000 -rc_registration: - per_second: 10000 - burst_count: 10000 - -rc_login: - address: - per_second: 10000 - burst_count: 10000 - account: - per_second: 10000 - burst_count: 10000 - failed_attempts: - per_second: 10000 - burst_count: 10000 - -media_store_path: "/data/media_store" -uploads_path: "/data/uploads" -enable_registration: true -enable_registration_without_verification: true -disable_msisdn_registration: false -registration_shared_secret: "{{REGISTRATION_SECRET}}" -report_stats: false -macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" -form_secret: "{{FORM_SECRET}}" -signing_key_path: "/data/localhost.signing.key" -email: - enable_notifs: false - smtp_host: "localhost" - smtp_port: 25 - smtp_user: "exampleusername" - smtp_pass: "examplepassword" - require_transport_security: False - notif_from: "Your Friendly %(app)s homeserver " - app_name: Matrix - notif_template_html: notif_mail.html - notif_template_text: notif_mail.txt - notif_for_new_users: True - client_base_url: "http://localhost/element" - -user_consent: - template_dir: /data/res/templates/privacy - version: 1.0 - server_notice_content: - msgtype: m.text - body: >- - To continue using this homeserver you must review and agree to the - terms and conditions at %(consent_uri)s - send_server_notice_to_guests: True - block_events_error: >- - To continue using this homeserver you must review and agree to the - terms and conditions at %(consent_uri)s - require_at_registration: true - -server_notices: - system_mxid_localpart: notices - system_mxid_display_name: "Server Notices" - system_mxid_avatar_url: "mxc://localhost:5005/oumMVlgDnLYFaPVkExemNVVZ" - room_name: "Server Notices" -trusted_key_servers: - - server_name: "matrix.org" -suppress_key_server_warning: true diff --git a/cypress/plugins/synapsedocker/templates/consent/log.config b/cypress/plugins/synapsedocker/templates/consent/log.config deleted file mode 100644 index b9123d0f5b93..000000000000 --- a/cypress/plugins/synapsedocker/templates/consent/log.config +++ /dev/null @@ -1,50 +0,0 @@ -# Log configuration for Synapse. -# -# This is a YAML file containing a standard Python logging configuration -# dictionary. See [1] for details on the valid settings. -# -# Synapse also supports structured logging for machine readable logs which can -# be ingested by ELK stacks. See [2] for details. -# -# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema -# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html - -version: 1 - -formatters: - precise: - format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' - -handlers: - # A handler that writes logs to stderr. Unused by default, but can be used - # instead of "buffer" and "file" in the logger handlers. - console: - class: logging.StreamHandler - formatter: precise - -loggers: - synapse.storage.SQL: - # beware: increasing this to DEBUG will make synapse log sensitive - # information such as access tokens. - level: DEBUG - - twisted: - # We send the twisted logging directly to the file handler, - # to work around https://github.com/matrix-org/synapse/issues/3471 - # when using "buffer" logger. Use "console" to log to stderr instead. - handlers: [console] - propagate: false - -root: - level: DEBUG - - # Write logs to the `buffer` handler, which will buffer them together in memory, - # then write them to a file. - # - # Replace "buffer" with "console" to log to stderr instead. (Note that you'll - # also need to update the configuration for the `twisted` logger above, in - # this case.) - # - handlers: [console] - -disable_existing_loggers: false diff --git a/cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/1.0.html b/cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/1.0.html deleted file mode 100644 index 8ee888518ab8..000000000000 --- a/cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/1.0.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - Test Privacy policy - - - {% if has_consented %} -

Thank you, you've already accepted the license.

- {% else %} -

Please accept the license!

-
- - - - -
- {% endif %} - - diff --git a/cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/success.html b/cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/success.html deleted file mode 100644 index 8db01e8a6e75..000000000000 --- a/cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/success.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - Test Privacy policy - - -

Danke schoen

- - diff --git a/cypress/plugins/synapsedocker/templates/default/README.md b/cypress/plugins/synapsedocker/templates/default/README.md deleted file mode 100644 index 8f6b11f999b8..000000000000 --- a/cypress/plugins/synapsedocker/templates/default/README.md +++ /dev/null @@ -1 +0,0 @@ -A synapse configured with user privacy consent disabled diff --git a/cypress/plugins/synapsedocker/templates/default/homeserver.yaml b/cypress/plugins/synapsedocker/templates/default/homeserver.yaml deleted file mode 100644 index e51ac1918ffb..000000000000 --- a/cypress/plugins/synapsedocker/templates/default/homeserver.yaml +++ /dev/null @@ -1,94 +0,0 @@ -server_name: "localhost" -pid_file: /data/homeserver.pid -public_baseurl: "{{PUBLIC_BASEURL}}" -listeners: - - port: 8008 - tls: false - bind_addresses: ["::"] - type: http - x_forwarded: true - - resources: - - names: [client] - compress: false - -database: - name: "sqlite3" - args: - database: ":memory:" - -log_config: "/data/log.config" - -rc_messages_per_second: 10000 -rc_message_burst_count: 10000 -rc_registration: - per_second: 10000 - burst_count: 10000 -rc_joins: - local: - per_second: 9999 - burst_count: 9999 - remote: - per_second: 9999 - burst_count: 9999 -rc_joins_per_room: - per_second: 9999 - burst_count: 9999 -rc_3pid_validation: - per_second: 1000 - burst_count: 1000 - -rc_invites: - per_room: - per_second: 1000 - burst_count: 1000 - per_user: - per_second: 1000 - burst_count: 1000 - -rc_login: - address: - per_second: 10000 - burst_count: 10000 - account: - per_second: 10000 - burst_count: 10000 - failed_attempts: - per_second: 10000 - burst_count: 10000 - -media_store_path: "/data/media_store" -uploads_path: "/data/uploads" -enable_registration: true -enable_registration_without_verification: true -disable_msisdn_registration: false -registration_shared_secret: "{{REGISTRATION_SECRET}}" -report_stats: false -macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" -form_secret: "{{FORM_SECRET}}" -signing_key_path: "/data/localhost.signing.key" - -trusted_key_servers: - - server_name: "matrix.org" -suppress_key_server_warning: true - -ui_auth: - session_timeout: "300s" - -oidc_providers: - - idp_id: test - idp_name: "OAuth test" - issuer: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth" - authorization_endpoint: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth/auth.html" - # the token endpoint receives requests from synapse, rather than the webapp, so needs to escape the docker container. - # Hence, HOST_DOCKER_INTERNAL rather than localhost. This is set to - # host.docker.internal on Docker and host.containers.internal on Podman. - token_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/token" - userinfo_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/userinfo" - client_id: "synapse" - discover: false - scopes: ["profile"] - skip_verification: true - user_mapping_provider: - config: - display_name_template: "{{ user.name }}" diff --git a/cypress/plugins/synapsedocker/templates/default/log.config b/cypress/plugins/synapsedocker/templates/default/log.config deleted file mode 100644 index b9123d0f5b93..000000000000 --- a/cypress/plugins/synapsedocker/templates/default/log.config +++ /dev/null @@ -1,50 +0,0 @@ -# Log configuration for Synapse. -# -# This is a YAML file containing a standard Python logging configuration -# dictionary. See [1] for details on the valid settings. -# -# Synapse also supports structured logging for machine readable logs which can -# be ingested by ELK stacks. See [2] for details. -# -# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema -# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html - -version: 1 - -formatters: - precise: - format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' - -handlers: - # A handler that writes logs to stderr. Unused by default, but can be used - # instead of "buffer" and "file" in the logger handlers. - console: - class: logging.StreamHandler - formatter: precise - -loggers: - synapse.storage.SQL: - # beware: increasing this to DEBUG will make synapse log sensitive - # information such as access tokens. - level: DEBUG - - twisted: - # We send the twisted logging directly to the file handler, - # to work around https://github.com/matrix-org/synapse/issues/3471 - # when using "buffer" logger. Use "console" to log to stderr instead. - handlers: [console] - propagate: false - -root: - level: DEBUG - - # Write logs to the `buffer` handler, which will buffer them together in memory, - # then write them to a file. - # - # Replace "buffer" with "console" to log to stderr instead. (Note that you'll - # also need to update the configuration for the `twisted` logger above, in - # this case.) - # - handlers: [console] - -disable_existing_loggers: false diff --git a/cypress/plugins/synapsedocker/templates/email/README.md b/cypress/plugins/synapsedocker/templates/email/README.md deleted file mode 100644 index 40c23ba0be42..000000000000 --- a/cypress/plugins/synapsedocker/templates/email/README.md +++ /dev/null @@ -1 +0,0 @@ -A synapse configured to require an email for registration diff --git a/cypress/plugins/synapsedocker/templates/email/homeserver.yaml b/cypress/plugins/synapsedocker/templates/email/homeserver.yaml deleted file mode 100644 index fc20641ab401..000000000000 --- a/cypress/plugins/synapsedocker/templates/email/homeserver.yaml +++ /dev/null @@ -1,44 +0,0 @@ -server_name: "localhost" -pid_file: /data/homeserver.pid -public_baseurl: "{{PUBLIC_BASEURL}}" -listeners: - - port: 8008 - tls: false - bind_addresses: ["::"] - type: http - x_forwarded: true - - resources: - - names: [client] - compress: false - -database: - name: "sqlite3" - args: - database: ":memory:" - -log_config: "/data/log.config" - -media_store_path: "/data/media_store" -uploads_path: "/data/uploads" -enable_registration: true -registrations_require_3pid: - - email -registration_shared_secret: "{{REGISTRATION_SECRET}}" -report_stats: false -macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" -form_secret: "{{FORM_SECRET}}" -signing_key_path: "/data/localhost.signing.key" - -trusted_key_servers: - - server_name: "matrix.org" -suppress_key_server_warning: true - -ui_auth: - session_timeout: "300s" - -email: - smtp_host: "%SMTP_HOST%" - smtp_port: %SMTP_PORT% - notif_from: "Your Friendly %(app)s homeserver " - app_name: my_branded_matrix_server diff --git a/cypress/plugins/synapsedocker/templates/email/log.config b/cypress/plugins/synapsedocker/templates/email/log.config deleted file mode 100644 index ac232762da3f..000000000000 --- a/cypress/plugins/synapsedocker/templates/email/log.config +++ /dev/null @@ -1,50 +0,0 @@ -# Log configuration for Synapse. -# -# This is a YAML file containing a standard Python logging configuration -# dictionary. See [1] for details on the valid settings. -# -# Synapse also supports structured logging for machine readable logs which can -# be ingested by ELK stacks. See [2] for details. -# -# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema -# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html - -version: 1 - -formatters: - precise: - format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' - -handlers: - # A handler that writes logs to stderr. Unused by default, but can be used - # instead of "buffer" and "file" in the logger handlers. - console: - class: logging.StreamHandler - formatter: precise - -loggers: - synapse.storage.SQL: - # beware: increasing this to DEBUG will make synapse log sensitive - # information such as access tokens. - level: INFO - - twisted: - # We send the twisted logging directly to the file handler, - # to work around https://github.com/matrix-org/synapse/issues/3471 - # when using "buffer" logger. Use "console" to log to stderr instead. - handlers: [console] - propagate: false - -root: - level: INFO - - # Write logs to the `buffer` handler, which will buffer them together in memory, - # then write them to a file. - # - # Replace "buffer" with "console" to log to stderr instead. (Note that you'll - # also need to update the configuration for the `twisted` logger above, in - # this case.) - # - handlers: [console] - -disable_existing_loggers: false diff --git a/cypress/plugins/webserver.ts b/cypress/plugins/webserver.ts deleted file mode 100644 index 55a25a313e39..000000000000 --- a/cypress/plugins/webserver.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import * as http from "http"; -import { AddressInfo } from "net"; - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; - -const servers: http.Server[] = []; - -function serveHtmlFile(html: string): string { - const server = http.createServer((req, res) => { - res.writeHead(200, { - "Content-Type": "text/html", - }); - res.end(html); - }); - server.listen(); - servers.push(server); - - return `http://localhost:${(server.address() as AddressInfo).port}/`; -} - -function stopWebServers(): null { - for (const server of servers) { - server.close(); - } - servers.splice(0, servers.length); // clear - - return null; // tell cypress we did the task successfully (doesn't allow undefined) -} - -export function webserver(on: PluginEvents, config: PluginConfigOptions) { - on("task", { serveHtmlFile, stopWebServers }); - on("after:run", stopWebServers); -} diff --git a/cypress/support/app.ts b/cypress/support/app.ts deleted file mode 100644 index 3e9d75173a2e..000000000000 --- a/cypress/support/app.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import Chainable = Cypress.Chainable; -import AUTWindow = Cypress.AUTWindow; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - /** - * Applies tweaks to the config read from config.json - */ - tweakConfig(tweaks: Record): Chainable; - } - } -} - -Cypress.Commands.add("tweakConfig", (tweaks: Record): Chainable => { - return cy.window().then((win) => { - // note: we can't *set* the object because the window version is effectively a pointer. - for (const [k, v] of Object.entries(tweaks)) { - // @ts-ignore - for some reason it's not picking up on global.d.ts types. - win.mxReactSdkConfig[k] = v; - } - }); -}); - -// Needed to make this file a module -export {}; diff --git a/cypress/support/axe.ts b/cypress/support/axe.ts deleted file mode 100644 index b83054290220..000000000000 --- a/cypress/support/axe.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import "cypress-axe"; -import * as axe from "axe-core"; - -import type { Options } from "cypress-axe"; -import Chainable = Cypress.Chainable; - -function terminalLog(violations: axe.Result[]): void { - cy.task( - "log", - `${violations.length} accessibility violation${violations.length === 1 ? "" : "s"} ${ - violations.length === 1 ? "was" : "were" - } detected`, - ); - - // pluck specific keys to keep the table readable - const violationData = violations.map(({ id, impact, description, nodes }) => ({ - id, - impact, - description, - nodes: nodes.length, - })); - - cy.task("table", violationData); -} - -Cypress.Commands.overwrite( - "checkA11y", - ( - originalFn: Chainable["checkA11y"], - context?: string | Node | axe.ContextObject | undefined, - options: Options = {}, - violationCallback?: ((violations: axe.Result[]) => void) | undefined, - skipFailures?: boolean, - ): void => { - return originalFn( - context, - { - ...options, - rules: { - // Disable contrast checking for now as we have too many issues with it - "color-contrast": { - enabled: false, - }, - ...options.rules, - }, - }, - violationCallback ?? terminalLog, - skipFailures, - ); - }, -); - -// Load axe-core into the window under test. -// -// The injectAxe in cypress-axe attempts to load axe via an `eval`. That conflicts with our CSP -// which disallows "unsafe-eval". So, replace it with an implementation that loads it via an -// injected + + Loading test data... + diff --git a/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/load.js b/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/load.js new file mode 100644 index 000000000000..ab167ced5b39 --- /dev/null +++ b/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/load.js @@ -0,0 +1,228 @@ +/* +Copyright 2023-2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* Browser-side javascript to fetch the indexeddb dump file, and populate indexeddb. */ + +/** The pickle key corresponding to the data dump. */ +const PICKLE_KEY = "+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o"; + +/** + * Populate an IndexedDB store with the test data from this directory. + * + * @param {any} data - IndexedDB dump to import + * @param {string} name - Name of the IndexedDB database to create. + */ +async function populateStore(data, name) { + const req = indexedDB.open(name, 11); + + const db = await new Promise((resolve, reject) => { + req.onupgradeneeded = (ev) => { + const db = req.result; + const oldVersion = ev.oldVersion; + upgradeDatabase(oldVersion, db); + }; + + req.onerror = (ev) => { + reject(req.error); + }; + + req.onsuccess = () => { + const db = req.result; + resolve(db); + }; + }); + + await importData(data, db); + + return db; +} + +/** + * Create the schema for the indexed db store + * + * @param {number} oldVersion - The current version of the store. + * @param {IDBDatabase} db - The indexeddb database. + */ +function upgradeDatabase(oldVersion, db) { + if (oldVersion < 1) { + const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" }); + outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]); + outgoingRoomKeyRequestsStore.createIndex("state", "state"); + } + + if (oldVersion < 2) { + db.createObjectStore("account"); + } + + if (oldVersion < 3) { + const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"] }); + sessionsStore.createIndex("deviceKey", "deviceKey"); + } + + if (oldVersion < 4) { + db.createObjectStore("inbound_group_sessions", { keyPath: ["senderCurve25519Key", "sessionId"] }); + } + + if (oldVersion < 5) { + db.createObjectStore("device_data"); + } + + if (oldVersion < 6) { + db.createObjectStore("rooms"); + } + + if (oldVersion < 7) { + db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"] }); + } + + if (oldVersion < 8) { + db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"] }); + } + + if (oldVersion < 9) { + const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"] }); + problemsStore.createIndex("deviceKey", "deviceKey"); + + db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"] }); + } + + if (oldVersion < 10) { + db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"] }); + } + + if (oldVersion < 11) { + db.createObjectStore("parked_shared_history", { keyPath: ["roomId"] }); + } +} + +/** Do the import of data into the database + * + * @param {any} json - The data to import. + * @param {IDBDatabase} db - The database to import into. + * @returns {Promise} + */ +async function importData(json, db) { + for (const [storeName, data] of Object.entries(json)) { + await new Promise((resolve, reject) => { + console.log(`Populating ${storeName} with test data`); + const store = db.transaction(storeName, "readwrite").objectStore(storeName); + + function putEntry(idx) { + if (idx >= data.length) { + resolve(undefined); + return; + } + + const { key, value } = data[idx]; + try { + const putReq = store.put(value, key); + putReq.onsuccess = (_) => putEntry(idx + 1); + putReq.onerror = (_) => reject(putReq.error); + } catch (e) { + throw new Error( + `Error populating '${storeName}' with key ${JSON.stringify(key)}, value ${JSON.stringify( + value, + )}: ${e}`, + ); + } + } + + putEntry(0); + }); + } +} + +function getPickleAdditionalData(userId, deviceId) { + const additionalData = new Uint8Array(userId.length + deviceId.length + 1); + for (let i = 0; i < userId.length; i++) { + additionalData[i] = userId.charCodeAt(i); + } + additionalData[userId.length] = 124; // "|" + for (let i = 0; i < deviceId.length; i++) { + additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i); + } + return additionalData; +} + +/** Save an entry to the `matrix-react-sdk` indexeddb database. + * + * If `matrix-react-sdk` does not yet exist, it will be created with the correct schema. + * + * @param {String} table + * @param {String} key + * @param {String} data + * @returns {Promise} + */ +async function idbSave(table, key, data) { + const idb = await new Promise((resolve, reject) => { + const request = indexedDB.open("matrix-react-sdk", 1); + request.onerror = reject; + request.onsuccess = () => { + resolve(request.result); + }; + request.onupgradeneeded = () => { + const db = request.result; + db.createObjectStore("pickleKey"); + db.createObjectStore("account"); + }; + }); + return await new Promise((resolve, reject) => { + const txn = idb.transaction([table], "readwrite"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.put(data, key); + request.onerror = reject; + request.onsuccess = resolve; + }); +} + +/** + * Save the pickle key to indexeddb, so that the app can read it. + * + * @param {String} userId - The user's ID (used in the encryption algorithm). + * @param {String} deviceId - The user's device ID (ditto). + * @returns {Promise} + */ +async function savePickleKey(userId, deviceId) { + const itFunc = function* () { + const decoded = atob(PICKLE_KEY); + for (let i = 0; i < decoded.length; ++i) { + yield decoded.charCodeAt(i); + } + }; + const decoded = Uint8Array.from(itFunc()); + + const cryptoKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + const iv = new Uint8Array(32); + crypto.getRandomValues(iv); + + const additionalData = getPickleAdditionalData(userId, deviceId); + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, cryptoKey, decoded); + + await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey }); +} + +async function loadDump() { + const dump = await fetch("dump.json"); + const indexedDbDump = await dump.json(); + await populateStore(indexedDbDump, "matrix-js-sdk:crypto"); + await savePickleKey(window.localStorage.getItem("mx_user_id"), window.localStorage.getItem("mx_device_id")); + console.log("Test data loaded; redirecting to main app"); + window.location.replace("/"); +} + +loadDump(); diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index c0120f395771..f1e1f811903c 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -14,9 +14,125 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type Page, expect } from "@playwright/test"; +import { type Page, expect, JSHandle } from "@playwright/test"; +import type { CryptoEvent, ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { + VerificationRequest, + Verifier, + EmojiMapping, + VerifierEvent, +} from "matrix-js-sdk/src/crypto-api/verification"; +import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; +import { Client } from "../../pages/client"; +import { ElementAppPage } from "../../pages/ElementAppPage"; + +/** + * wait for the given client to receive an incoming verification request, and automatically accept it + * + * @param client - matrix client handle we expect to receive a request + */ +export async function waitForVerificationRequest(client: Client): Promise> { + return client.evaluateHandle((cli) => { + return new Promise((resolve) => { + console.log("~~"); + const onVerificationRequestEvent = async (request: VerificationRequest) => { + console.log("@@", request); + await request.accept(); + resolve(request); + }; + cli.once( + "crypto.verificationRequestReceived" as CryptoEvent.VerificationRequestReceived, + onVerificationRequestEvent, + ); + }); + }); +} + +/** + * Automatically handle a SAS verification + * + * Given a verifier which has already been started, wait for the emojis to be received, blindly confirm they + * match, and return them + * + * @param verifier - verifier + * @returns A promise that resolves, with the emoji list, once we confirm the emojis + */ +export function handleSasVerification(verifier: JSHandle): Promise { + return verifier.evaluate((verifier) => { + const event = verifier.getShowSasCallbacks(); + if (event) return event.sas.emoji; + + return new Promise((resolve) => { + const onShowSas = (event: ISasEvent) => { + verifier.off("show_sas" as VerifierEvent, onShowSas); + event.confirm(); + resolve(event.sas.emoji); + }; + + verifier.on("show_sas" as VerifierEvent, onShowSas); + }); + }); +} + +/** + * Check that the user has published cross-signing keys, and that the user's device has been cross-signed. + */ +export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise { + const { userId, deviceId, keys } = await app.client.evaluate(async (cli: MatrixClient) => { + const deviceId = cli.getDeviceId(); + const userId = cli.getUserId(); + const keys = await cli.downloadKeysForUsers([userId]); + + return { userId, deviceId, keys }; + }); + + // there should be three cross-signing keys + expect(keys.master_keys[userId]).toHaveProperty("keys"); + expect(keys.self_signing_keys[userId]).toHaveProperty("keys"); + expect(keys.user_signing_keys[userId]).toHaveProperty("keys"); + + // and the device should be signed by the self-signing key + const selfSigningKeyId = Object.keys(keys.self_signing_keys[userId].keys)[0]; + + expect(keys.device_keys[userId][deviceId]).toBeDefined(); + + const myDeviceSignatures = keys.device_keys[userId][deviceId].signatures[userId]; + expect(myDeviceSignatures[selfSigningKeyId]).toBeDefined(); +} + +/** + * Check that the current device is connected to the expected key backup. + * Also checks that the decryption key is known and cached locally. + * + * @param page - the page to check + * @param expectedBackupVersion - the version of the backup we expect to be connected to. + * @param checkBackupKeyInCache - whether to check that the backup key is cached locally. + */ +export async function checkDeviceIsConnectedKeyBackup( + page: Page, + expectedBackupVersion: string, + checkBackupKeyInCache: boolean, +): Promise { + await page.getByRole("button", { name: "User menu" }).click(); + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click(); + await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible(); + + // expand the advanced section to see the active version in the reports + await page.locator(".mx_SecureBackupPanel_advanced").locator("..").click(); + + if (checkBackupKeyInCache) { + const cacheDecryptionKeyStatusElement = page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(2) td"); + await expect(cacheDecryptionKeyStatusElement).toHaveText("cached locally, well formed"); + } + + await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText( + expectedBackupVersion + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)", + ); + + await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(expectedBackupVersion); +} /** * Fill in the login form in element with the given creds. @@ -52,3 +168,95 @@ export async function logIntoElement( await page.getByRole("button", { name: "Done" }).click(); } } + +export async function logOutOfElement(page: Page) { + await page.getByRole("button", { name: "User menu" }).click(); + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click(); + await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click(); + + // Wait for the login page to load + await page.getByRole("heading", { name: "Sign in" }).click(); +} + +/** + * Given a SAS verifier for a bot client: + * - wait for the bot to receive the emojis + * - check that the bot sees the same emoji as the application + * + * @param verifier - a verifier in a bot client + */ +export async function doTwoWaySasVerification(page: Page, verifier: JSHandle): Promise { + // on the bot side, wait for the emojis, confirm they match, and return them + const emojis = await handleSasVerification(verifier); + + const emojiBlocks = page.locator(".mx_VerificationShowSas_emojiSas_block"); + await expect(emojiBlocks).toHaveCount(emojis.length); + + // then, check that our application shows an emoji panel with the same emojis. + for (let i = 0; i < emojis.length; i++) { + const emoji = emojis[i]; + const emojiBlock = emojiBlocks.nth(i); + const textContent = await emojiBlock.textContent(); + // VerificationShowSas munges the case of the emoji descriptions returned by the js-sdk before + // displaying them. Once we drop support for legacy crypto, that code can go away, and so can the + // case-munging here. + expect(textContent.toLowerCase()).toEqual(emoji[0] + emoji[1].toLowerCase()); + } +} + +/** + * Open the security settings and enable secure key backup. + * + * Assumes that the current device has been cross-signed (which means that we skip a step where we set it up). + * + * Returns the security key + */ +export async function enableKeyBackup(app: ElementAppPage): Promise { + await app.settings.openUserSettings("Security & Privacy"); + await app.page.getByRole("button", { name: "Set up Secure Backup" }).click(); + const dialog = app.page.locator(".mx_Dialog"); + // Recovery key is selected by default + await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 }); + + // copy the text ourselves + const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent(); + await copyAndContinue(app.page); + + await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); + await dialog.getByRole("button", { name: "Done" }).click(); + await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); + + return securityKey; +} + +/** + * Click on copy and continue buttons to dismiss the security key dialog + */ +export async function copyAndContinue(page: Page) { + await page.getByRole("button", { name: "Copy" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); +} + +/** + * Create a shared, unencrypted room with the given user, and wait for them to join + * + * @param other - UserID of the other user + * @param opts - other options for the createRoom call + * + * @returns a promise which resolves to the room ID + */ +export async function createSharedRoomWithUser( + app: ElementAppPage, + other: string, + opts: Omit = { name: "TestRoom" }, +): Promise { + const roomId = await app.client.createRoom({ ...opts, invite: [other] }); + + await app.viewRoomById(roomId); + + // wait for the other user to join the room, otherwise our attempt to open his user details may race + // with his join. + await expect(app.page.getByText(" joined the room", { exact: false })).toBeVisible(); + + return roomId; +} diff --git a/playwright/e2e/crypto/verification.spec.ts b/playwright/e2e/crypto/verification.spec.ts new file mode 100644 index 000000000000..55d65a9b087b --- /dev/null +++ b/playwright/e2e/crypto/verification.spec.ts @@ -0,0 +1,361 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import jsQR from "jsqr"; + +import type { JSHandle, Locator, Page } from "@playwright/test"; +import type { Preset, Visibility } from "matrix-js-sdk/src/matrix"; +import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api"; +import { test, expect } from "../../element-web-test"; +import { + checkDeviceIsConnectedKeyBackup, + checkDeviceIsCrossSigned, + doTwoWaySasVerification, + logIntoElement, + waitForVerificationRequest, +} from "./utils"; +import { Client } from "../../pages/client"; +import { Bot } from "../../pages/bot"; + +test.describe("Device verification", () => { + let aliceBotClient: Bot; + + /** The backup version that was set up by the bot client. */ + let expectedBackupVersion: string; + + test.beforeEach(async ({ page, homeserver, credentials }) => { + // Visit the login page of the app, to load the matrix sdk + await page.goto("/#/login"); + + await page.pause(); + + // wait for the page to load + await page.waitForSelector(".mx_AuthPage", { timeout: 30000 }); + + // Create a new device for alice + aliceBotClient = new Bot(page, homeserver, { + rustCrypto: true, + bootstrapCrossSigning: true, + bootstrapSecretStorage: true, + }); + aliceBotClient.setCredentials(credentials); + const mxClientHandle = await aliceBotClient.prepareClient(); + + await page.waitForTimeout(20000); + + expectedBackupVersion = await mxClientHandle.evaluate(async (mxClient) => { + return await mxClient.getCrypto()!.getActiveSessionBackupVersion(); + }); + }); + + // Click the "Verify with another device" button, and have the bot client auto-accept it. + async function initiateAliceVerificationRequest(page: Page): Promise> { + // alice bot waits for verification request + const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient); + + // Click on "Verify with another device" + await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with another device" }).click(); + + // alice bot responds yes to verification request from alice + return promiseVerificationRequest; + } + + test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => { + await logIntoElement(page, homeserver, credentials); + + // Launch the verification request between alice and the bot + const verificationRequest = await initiateAliceVerificationRequest(page); + + // Handle emoji SAS verification + const infoDialog = page.locator(".mx_InfoDialog"); + // the bot chooses to do an emoji verification + const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1")); + + // Handle emoji request and check that emojis are matching + await doTwoWaySasVerification(page, verifier); + + await infoDialog.getByRole("button", { name: "They match" }).click(); + await infoDialog.getByRole("button", { name: "Got it" }).click(); + + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); + + // Check that the current device is connected to key backup + // For now we don't check that the backup key is in cache because it's a bit flaky, + // as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically. + await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false); + }); + + test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => { + // A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key" + await logIntoElement(page, homeserver, credentials); + + // Launch the verification request between alice and the bot + const verificationRequest = await initiateAliceVerificationRequest(page); + + const infoDialog = page.locator(".mx_InfoDialog"); + // feed the QR code into the verification request. + const qrData = await readQrCode(infoDialog); + const verifier = await verificationRequest.evaluateHandle( + (request, qrData) => request.scanQRCode(new Uint8Array(qrData)), + [...qrData], + ); + + // Confirm that the bot user scanned successfully + await expect(infoDialog.getByText("Almost there! Is your other device showing the same shield?")).toBeVisible(); + await infoDialog.getByRole("button", { name: "Yes" }).click(); + await infoDialog.getByRole("button", { name: "Got it" }).click(); + + // wait for the bot to see we have finished + await verifier.evaluate((verifier) => verifier.verify()); + + // the bot uploads the signatures asynchronously, so wait for that to happen + await page.waitForTimeout(1000); + + // our device should trust the bot device + await app.client.evaluate(async (cli, aliceBotCredentials) => { + const deviceStatus = await cli + .getCrypto()! + .getDeviceVerificationStatus(aliceBotCredentials.userId, aliceBotCredentials.deviceId); + if (!deviceStatus.isVerified()) { + throw new Error("Bot device was not verified after QR code verification"); + } + }, aliceBotClient.credentials); + + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); + + // Check that the current device is connected to key backup + // For now we don't check that the backup key is in cache because it's a bit flaky, + // as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically. + await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false); + }); + + test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => { + await logIntoElement(page, homeserver, credentials); + + // Select the security phrase + await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click(); + + // Fill the passphrase + const dialog = page.locator(".mx_Dialog"); + await dialog.locator("input").fill("new passphrase"); + await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + + await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click(); + + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); + + // Check that the current device is connected to key backup + // The backup decryption key should be in cache also, as we got it directly from the 4S + await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true); + }); + + test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => { + await logIntoElement(page, homeserver, credentials); + + // Select the security phrase + await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click(); + + // Fill the security key + const dialog = page.locator(".mx_Dialog"); + await dialog.getByRole("button", { name: "use your Security Key" }).click(); + const aliceRecoveryKey = await aliceBotClient.getRecoveryKey(); + await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey); + await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + + await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click(); + + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); + + // Check that the current device is connected to key backup + // The backup decryption key should be in cache also, as we got it directly from the 4S + await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true); + }); + + test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => { + await logIntoElement(page, homeserver, credentials); + + /* Dismiss "Verify this device" */ + const authPage = page.locator(".mx_AuthPage"); + await authPage.getByRole("button", { name: "Skip verification for now" }).click(); + await authPage.getByRole("button", { name: "I'll verify later" }).click(); + + await page.waitForSelector(".mx_MatrixChat"); + const elementDeviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId()); + + /* Now initiate a verification request from the *bot* device. */ + const botVerificationRequest = await aliceBotClient.evaluateHandle( + async (client, { userId, deviceId }) => { + return client.getCrypto()!.requestDeviceVerification(userId, deviceId); + }, + { userId: credentials.userId, deviceId: elementDeviceId }, + ); + + /* Check the toast for the incoming request */ + const toast = await toasts.getToast("Verification requested"); + // it should contain the device ID of the requesting device + await expect(toast.getByText(`${aliceBotClient.credentials.deviceId} from `)).toBeVisible(); + // Accept + await toast.getByRole("button", { name: "Verify Session" }).click(); + + /* Click 'Start' to start SAS verification */ + await page.getByRole("button", { name: "Start" }).click(); + + /* on the bot side, wait for the verifier to exist ... */ + const verifier = await awaitVerifier(botVerificationRequest); + // ... confirm ... + botVerificationRequest.evaluate((verificationRequest) => verificationRequest.verifier.verify()); + // ... and then check the emoji match + await doTwoWaySasVerification(page, verifier); + + /* And we're all done! */ + const infoDialog = page.locator(".mx_InfoDialog"); + await infoDialog.getByRole("button", { name: "They match" }).click(); + await expect( + infoDialog.getByText(`You've successfully verified (${aliceBotClient.credentials.deviceId})!`), + ).toBeVisible(); + await infoDialog.getByRole("button", { name: "Got it" }).click(); + }); +}); + +test.describe("User verification", () => { + // note that there are other tests that check user verification works in `crypto.spec.ts`. + + test.use({ + displayName: "Alice", + botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" }, + }); + + test("can receive a verification request when there is no existing DM", async ({ + page, + app, + bot: bob, + user: aliceCredentials, + toasts, + }) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + + // the other user creates a DM + const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); + + // accept the DM + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Start chatting" }).click(); + + // once Alice has joined, Bob starts the verification + const bobVerificationRequest = await bob.evaluateHandle( + async (client, { dmRoomId, aliceCredentials }) => { + const room = client.getRoom(dmRoomId); + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + room.once(window.matrixcs.RoomStateEvent.Members, resolve); + }); + } + + return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + }, + { dmRoomId, aliceCredentials }, + ); + + // there should also be a toast + const toast = await toasts.getToast("Verification requested"); + // it should contain the details of the requesting user + await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible(); + // Accept + await toast.getByRole("button", { name: "Verify Session" }).click(); + + // request verification by emoji + await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); + + /* on the bot side, wait for the verifier to exist ... */ + const botVerifier = await awaitVerifier(bobVerificationRequest); + // ... confirm ... + botVerifier.evaluate((verifier) => verifier.verify()); + // ... and then check the emoji match + await doTwoWaySasVerification(page, botVerifier); + + await page.getByRole("button", { name: "They match" }).click(); + await expect(page.getByText("You've successfully verified Bob!")).toBeVisible(); + await page.getByRole("button", { name: "Got it" }).click(); + }); +}); + +/** Extract the qrcode out of an on-screen html element */ +async function readQrCode(base: Locator) { + const qrCode = base.locator('[alt="QR Code"]'); + const imageData = await qrCode.evaluate< + { + colorSpace: PredefinedColorSpace; + width: number; + height: number; + buffer: number[]; + }, + HTMLImageElement + >(async (img) => { + // draw the image on a canvas + const myCanvas = new OffscreenCanvas(img.width, img.height); + const ctx = myCanvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + + // read the image data + const imageData = ctx.getImageData(0, 0, myCanvas.width, myCanvas.height); + return { + colorSpace: imageData.colorSpace, + width: imageData.width, + height: imageData.height, + buffer: [...new Uint8ClampedArray(imageData.data.buffer)], + }; + }); + + // now we can decode the QR code. + const result = jsQR(new Uint8ClampedArray(imageData.buffer), imageData.width, imageData.height); + return new Uint8Array(result.binaryData); +} + +async function createDMRoom(client: Client, userId: string): Promise { + return client.createRoom({ + preset: "trusted_private_chat" as Preset, + visibility: "private" as Visibility, + invite: [userId], + is_direct: true, + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); +} + +/** + * Wait for a verifier to exist for a VerificationRequest + * + * @param botVerificationRequest + */ +async function awaitVerifier(botVerificationRequest: JSHandle): Promise> { + return botVerificationRequest.evaluateHandle(async (verificationRequest) => { + while (!verificationRequest.verifier) { + await new Promise((r) => verificationRequest.once("change" as any, r)); + } + return verificationRequest.verifier; + }); +} diff --git a/playwright/e2e/editing/editing.spec.ts b/playwright/e2e/editing/editing.spec.ts index 2a95b4884a43..7ed5d136fe3e 100644 --- a/playwright/e2e/editing/editing.spec.ts +++ b/playwright/e2e/editing/editing.spec.ts @@ -291,6 +291,7 @@ test.describe("Editing", () => { await editComposer.press("Backspace"); await editComposer.press("Backspace"); await editComposer.press("Enter"); + await app.getComposerField().hover(); // XXX: move the hover to get rid of the "Edit" tooltip await checkA11y(); } await expect( diff --git a/playwright/e2e/integration-manager/get-openid-token.spec.ts b/playwright/e2e/integration-manager/get-openid-token.spec.ts new file mode 100644 index 000000000000..c107bb2cbcba --- /dev/null +++ b/playwright/e2e/integration-manager/get-openid-token.spec.ts @@ -0,0 +1,128 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Page } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { openIntegrationManager } from "./utils"; + +const ROOM_NAME = "Integration Manager Test"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + +

No response

+ + + +`; + +async function sendActionFromIntegrationManager(page: Page, integrationManagerUrl: string) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.getByRole("button", { name: "Press to send action" }).click(); +} + +test.describe("Integration Manager: Get OpenID Token", () => { + test.use({ + displayName: "Alice", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + }); + await use({ roomId }); + }, + }); + + let integrationManagerUrl: string; + test.beforeEach(async ({ page, webserver }) => { + integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML); + + await page.addInitScript( + ({ token, integrationManagerUrl }) => { + window.localStorage.setItem("mx_scalar_token", token); + window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token); + }, + { + token: INTEGRATION_MANAGER_TOKEN, + integrationManagerUrl, + }, + ); + }); + + test.beforeEach(async ({ page, user, app, room }) => { + await app.client.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }); + + // Succeed when checking the token is valid + await page.route( + `${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, + async (route) => { + await route.fulfill({ + json: { + user_id: user.userId, + }, + }); + }, + ); + + await app.viewRoomByName(ROOM_NAME); + }); + + test("should successfully obtain an openID token", async ({ page }) => { + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl); + + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response").getByText(/access_token/)).toBeVisible(); + }); +}); diff --git a/playwright/e2e/integration-manager/kick.spec.ts b/playwright/e2e/integration-manager/kick.spec.ts new file mode 100644 index 000000000000..b5ca6a1b3a52 --- /dev/null +++ b/playwright/e2e/integration-manager/kick.spec.ts @@ -0,0 +1,226 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Page } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { openIntegrationManager } from "./utils"; + +const ROOM_NAME = "Integration Manager Test"; +const USER_DISPLAY_NAME = "Alice"; +const BOT_DISPLAY_NAME = "Bob"; +const KICK_REASON = "Goodbye"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + + + +`; + +async function closeIntegrationManager(page: Page, integrationManagerUrl: string) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.getByRole("button", { name: "Press to close" }).click(); +} + +async function sendActionFromIntegrationManager( + page: Page, + integrationManagerUrl: string, + targetRoomId: string, + targetUserId: string, +) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.locator("#target-room-id").fill(targetRoomId); + await iframe.locator("#target-user-id").fill(targetUserId); + await iframe.getByRole("button", { name: "Press to send action" }).click(); +} + +async function clickUntilGone(page: Page, selector: string, attempt = 0) { + if (attempt === 11) { + throw new Error("clickUntilGone attempt count exceeded"); + } + + await page.locator(selector).last().click(); + + const count = await page.locator(selector).count(); + if (count > 0) { + return clickUntilGone(page, selector, ++attempt); + } +} + +async function expectKickedMessage(page: Page, shouldExist: boolean) { + // Expand any event summaries, we can't use a click multiple here because clicking one might de-render others + // This is quite horrible but seems the most stable way of clicking 0-N buttons, + // one at a time with a full re-evaluation after each click + await clickUntilGone(page, ".mx_GenericEventListSummary_toggle[aria-expanded=false]"); + + // Check for the event message (or lack thereof) + await expect(page.getByText(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`)).toBeVisible({ + visible: shouldExist, + }); +} + +test.describe("Integration Manager: Kick", () => { + test.use({ + displayName: "Alice", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + }); + await use({ roomId }); + }, + botCreateOpts: { + displayName: BOT_DISPLAY_NAME, + autoAcceptInvites: true, + }, + }); + + let integrationManagerUrl: string; + test.beforeEach(async ({ page, webserver }) => { + integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML); + + await page.addInitScript( + ({ token, integrationManagerUrl }) => { + window.localStorage.setItem("mx_scalar_token", token); + window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token); + }, + { + token: INTEGRATION_MANAGER_TOKEN, + integrationManagerUrl, + }, + ); + }); + + test.beforeEach(async ({ page, user, app, room }) => { + await app.client.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }); + + // Succeed when checking the token is valid + await page.route( + `${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, + async (route) => { + await route.fulfill({ + json: { + user_id: user.userId, + }, + }); + }, + ); + + await app.viewRoomByName(ROOM_NAME); + }); + + test("should kick the target", async ({ page, app, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, true); + }); + + test("should not kick the target if lacking permissions", async ({ page, app, user, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); + + await app.client.sendStateEvent(room.roomId, "m.room.power_levels", { + kick: 50, + users: { + [user.userId]: 0, + }, + }); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, false); + }); + + test("should no-op if the target already left", async ({ page, app, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); + await targetUser.leave(room.roomId); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, false); + }); + + test("should no-op if the target was banned", async ({ page, app, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); + await app.client.ban(room.roomId, targetUser.credentials.userId); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, false); + }); + + test("should no-op if the target was never a room member", async ({ page, app, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, false); + }); +}); diff --git a/playwright/e2e/integration-manager/read_events.spec.ts b/playwright/e2e/integration-manager/read_events.spec.ts new file mode 100644 index 000000000000..b178596674d1 --- /dev/null +++ b/playwright/e2e/integration-manager/read_events.spec.ts @@ -0,0 +1,233 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Page } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { openIntegrationManager } from "./utils"; + +const ROOM_NAME = "Integration Manager Test"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + +

No response

+ + + +`; + +async function sendActionFromIntegrationManager( + page: Page, + integrationManagerUrl: string, + targetRoomId: string, + eventType: string, + stateKey: string | boolean, +) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.locator("#target-room-id").fill(targetRoomId); + await iframe.locator("#event-type").fill(eventType); + await iframe.locator("#state-key").fill(JSON.stringify(stateKey)); + await iframe.locator("#send-action").click(); +} + +test.describe("Integration Manager: Read Events", () => { + test.use({ + displayName: "Alice", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + }); + await use({ roomId }); + }, + }); + + let integrationManagerUrl: string; + test.beforeEach(async ({ page, webserver }) => { + integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML); + + await page.addInitScript( + ({ token, integrationManagerUrl }) => { + window.localStorage.setItem("mx_scalar_token", token); + window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token); + }, + { + token: INTEGRATION_MANAGER_TOKEN, + integrationManagerUrl, + }, + ); + }); + + test.beforeEach(async ({ page, user, app, room }) => { + await app.client.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }); + + // Succeed when checking the token is valid + await page.route( + `${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, + async (route) => { + await route.fulfill({ + json: { + user_id: user.userId, + }, + }); + }, + ); + + await app.viewRoomByName(ROOM_NAME); + }); + + test("should read a state event by state key", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = "state-key-123"; + + // Send a state event + const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey); + await openIntegrationManager(page); + + // Read state events + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponse.event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent)}`); + }); + + test("should read a state event with empty state key", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send a state event + const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey); + await openIntegrationManager(page); + + // Read state events + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponse.event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent)}`); + }); + + test("should read state events with any state key", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + + const stateKey1 = "state-key-123"; + const eventContent1 = { + foo1: "bar1", + }; + const stateKey2 = "state-key-456"; + const eventContent2 = { + foo2: "bar2", + }; + const stateKey3 = "state-key-789"; + const eventContent3 = { + foo3: "bar3", + }; + + // Send state events + const sendEventResponses = await Promise.all([ + app.client.sendStateEvent(room.roomId, eventType, eventContent1, stateKey1), + app.client.sendStateEvent(room.roomId, eventType, eventContent2, stateKey2), + app.client.sendStateEvent(room.roomId, eventType, eventContent3, stateKey3), + ]); + + await openIntegrationManager(page); + + // Read state events + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + true, // Any state key + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponses[0].event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent1)}`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponses[1].event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent2)}`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponses[2].event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent3)}`); + }); + + test("should fail to read an event type which is not allowed", async ({ page, room }) => { + const eventType = "com.example.event"; + const stateKey = ""; + + await openIntegrationManager(page); + + // Read state events + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("Failed to read events"); + }); +}); diff --git a/playwright/e2e/integration-manager/send_event.spec.ts b/playwright/e2e/integration-manager/send_event.spec.ts new file mode 100644 index 000000000000..61bad8a3ec78 --- /dev/null +++ b/playwright/e2e/integration-manager/send_event.spec.ts @@ -0,0 +1,255 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Page } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { openIntegrationManager } from "./utils"; + +const ROOM_NAME = "Integration Manager Test"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + + +

No response

+ + + +`; + +async function sendActionFromIntegrationManager( + page: Page, + integrationManagerUrl: string, + targetRoomId: string, + eventType: string, + stateKey: string, + content: Record, +) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.locator("#target-room-id").fill(targetRoomId); + await iframe.locator("#event-type").fill(eventType); + if (stateKey) { + await iframe.locator("#state-key").fill(stateKey); + } + await iframe.locator("#event-content").fill(JSON.stringify(content)); + await iframe.locator("#send-action").click(); +} + +test.describe("Integration Manager: Send Event", () => { + test.use({ + displayName: "Alice", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + }); + await use({ roomId }); + }, + }); + + let integrationManagerUrl: string; + test.beforeEach(async ({ page, webserver }) => { + integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML); + + await page.addInitScript( + ({ token, integrationManagerUrl }) => { + window.localStorage.setItem("mx_scalar_token", token); + window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token); + }, + { + token: INTEGRATION_MANAGER_TOKEN, + integrationManagerUrl, + }, + ); + }); + + test.beforeEach(async ({ page, user, app, room }) => { + await app.client.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }); + + // Succeed when checking the token is valid + await page.route( + `${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, + async (route) => { + await route.fulfill({ + json: { + user_id: user.userId, + }, + }); + }, + ); + + await app.viewRoomByName(ROOM_NAME); + await openIntegrationManager(page); + }); + + test("should send a state event", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = "state-key-123"; + + // Send the event + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + stateKey, + eventContent, + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("event_id"); + + // Check the event + const event = await app.client.evaluate( + (cli, { room, eventType, stateKey }) => { + return cli.getStateEvent(room.roomId, eventType, stateKey); + }, + { room, eventType, stateKey }, + ); + expect(event).toMatchObject(eventContent); + }); + + test("should send a state event with empty content", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = {}; + const stateKey = "state-key-123"; + + // Send the event + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + stateKey, + eventContent, + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("event_id"); + + // Check the event + const event = await app.client.evaluate( + (cli, { room, eventType, stateKey }) => { + return cli.getStateEvent(room.roomId, eventType, stateKey); + }, + { room, eventType, stateKey }, + ); + expect(event).toMatchObject({}); + }); + + test("should send a state event with empty state key", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send the event + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + stateKey, + eventContent, + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("event_id"); + + // Check the event + const event = await app.client.evaluate( + (cli, { room, eventType, stateKey }) => { + return cli.getStateEvent(room.roomId, eventType, stateKey); + }, + { room, eventType, stateKey }, + ); + expect(event).toMatchObject(eventContent); + }); + + test("should fail to send an event type which is not allowed", async ({ page, room }) => { + const eventType = "com.example.event"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send the event + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + stateKey, + eventContent, + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("Failed to send event"); + }); +}); diff --git a/cypress/plugins/utils/port.ts b/playwright/e2e/integration-manager/utils.ts similarity index 58% rename from cypress/plugins/utils/port.ts rename to playwright/e2e/integration-manager/utils.ts index 156ba866d5e3..259ff732c798 100644 --- a/cypress/plugins/utils/port.ts +++ b/playwright/e2e/integration-manager/utils.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,14 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as net from "net"; +import type { Page } from "@playwright/test"; -export async function getFreePort(): Promise { - return new Promise((resolve) => { - const srv = net.createServer(); - srv.listen(0, () => { - const port = (srv.address()).port; - srv.close(() => resolve(port)); - }); - }); +export async function openIntegrationManager(page: Page) { + await page.getByRole("button", { name: "Room info" }).click(); + await page + .locator(".mx_RoomSummaryCard_appsGroup") + .getByRole("button", { name: "Add widgets, bridges & bots" }) + .click(); } diff --git a/playwright/e2e/knock/create-knock-room.spec.ts b/playwright/e2e/knock/create-knock-room.spec.ts new file mode 100644 index 000000000000..8763c0fd6a80 --- /dev/null +++ b/playwright/e2e/knock/create-knock-room.spec.ts @@ -0,0 +1,92 @@ +/* +Copyright 2022-2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; +import { waitForRoom } from "../utils"; +import { Filter } from "../../pages/Spotlight"; + +test.describe("Create Knock Room", () => { + test.use({ + displayName: "Alice", + labsFlags: ["feature_ask_to_join"], + }); + + test("should create a knock room", async ({ page, app, user }) => { + const dialog = await app.openCreateRoomDialog(); + await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); + await dialog.getByRole("button", { name: "Room visibility" }).click(); + await dialog.getByRole("option", { name: "Ask to join" }).click(); + await dialog.getByRole("button", { name: "Create room" }).click(); + + await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible(); + + const urlHash = await page.evaluate(() => window.location.hash); + const roomId = urlHash.replace("#/room/", ""); + + // Room should have a knock join rule + await waitForRoom(page, app.client, roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some((e) => e.getType() === "m.room.join_rules" && e.getContent().join_rule === "knock"); + }); + }); + + test("should create a room and change a join rule to knock", async ({ page, app, user }) => { + const dialog = await app.openCreateRoomDialog(); + await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); + await dialog.getByRole("button", { name: "Create room" }).click(); + + await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible(); + + const urlHash = await page.evaluate(() => window.location.hash); + const roomId = urlHash.replace("#/room/", ""); + + await app.settings.openRoomSettings("Security & Privacy"); + + const settingsGroup = page.getByRole("group", { name: "Access" }); + await expect(settingsGroup.getByRole("radio", { name: "Private (invite only)" })).toBeChecked(); + await settingsGroup.getByText("Ask to join").click(); + + // Room should have a knock join rule + await waitForRoom(page, app.client, roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some((e) => e.getType() === "m.room.join_rules" && e.getContent().join_rule === "knock"); + }); + }); + + test("should create a public knock room", async ({ page, app, user }) => { + const dialog = await app.openCreateRoomDialog(); + await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); + await dialog.getByRole("button", { name: "Room visibility" }).click(); + await dialog.getByRole("option", { name: "Ask to join" }).click(); + await dialog.getByText("Make this room visible in the public room directory.").click(); + await dialog.getByRole("button", { name: "Create room" }).click(); + + await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible(); + + const urlHash = await page.evaluate(() => window.location.hash); + const roomId = urlHash.replace("#/room/", ""); + + // Room should have a knock join rule + await waitForRoom(page, app.client, roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some((e) => e.getType() === "m.room.join_rules" && e.getContent().join_rule === "knock"); + }); + + const spotlightDialog = await app.openSpotlight(); + await spotlightDialog.filter(Filter.PublicRooms); + await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); + }); +}); diff --git a/playwright/e2e/knock/knock-into-room.spec.ts b/playwright/e2e/knock/knock-into-room.spec.ts new file mode 100644 index 000000000000..21c5a145e3fd --- /dev/null +++ b/playwright/e2e/knock/knock-into-room.spec.ts @@ -0,0 +1,301 @@ +/* +Copyright 2023 Mikhail Aheichyk +Copyright 2023 Nordeck IT + Consulting GmbH. +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Visibility } from "matrix-js-sdk/src/matrix"; +import { test, expect } from "../../element-web-test"; +import { waitForRoom } from "../utils"; +import { Filter } from "../../pages/Spotlight"; + +test.describe("Knock Into Room", () => { + test.use({ + displayName: "Alice", + labsFlags: ["feature_ask_to_join"], + botCreateOpts: { + displayName: "Bob", + }, + room: async ({ bot }, use) => { + const roomId = await bot.createRoom({ + name: "Cybersecurity", + initial_state: [ + { + type: "m.room.join_rules", + content: { + join_rule: "knock", + }, + state_key: "", + }, + ], + }); + await use({ roomId }); + }, + }); + + test("should knock into the room then knock is approved and user joins the room then user is kicked and joins again", async ({ + page, + app, + user, + bot, + room, + }) => { + await app.viewRoomById(room.roomId); + + const roomPreviewBar = page.locator(".mx_RoomPreviewBar"); + await roomPreviewBar.getByRole("button", { name: "Join the discussion" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible(); + await expect(roomPreviewBar.getByRole("textbox")).toBeVisible(); + await roomPreviewBar.getByRole("button", { name: "Request access" }).click(); + + await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible(); + + // Knocked room should appear in Rooms + await expect( + page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + ).toBeVisible(); + + // bot waits for knock request from Alice + await waitForRoom(page, bot, room.roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "m.room.member" && + e.getContent()?.membership === "knock" && + e.getContent()?.displayname === "Alice", + ); + }); + + // bot invites Alice + await bot.inviteUser(room.roomId, user.userId); + + await expect( + page.getByRole("group", { name: "Invites" }).getByRole("treeitem", { name: "Cybersecurity" }), + ).toBeVisible(); + + // Alice have to accept invitation in order to join the room. + // It will be not needed when homeserver implements auto accept knock requests. + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + await expect( + page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + ).toBeVisible(); + + await expect(page.getByText("Alice joined the room")).toBeVisible(); + + // bot kicks Alice + await bot.kick(room.roomId, user.userId); + + await roomPreviewBar.getByRole("button", { name: "Re-join" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join Cybersecurity?" })).toBeVisible(); + await roomPreviewBar.getByRole("button", { name: "Request access" }).click(); + + // bot waits for knock request from Alice + await waitForRoom(page, bot, room.roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "m.room.member" && + e.getContent()?.membership === "knock" && + e.getContent()?.displayname === "Alice", + ); + }); + + // bot invites Alice + await bot.inviteUser(room.roomId, user.userId); + + // Alice have to accept invitation in order to join the room. + // It will be not needed when homeserver implements auto accept knock requests. + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + await expect(page.getByText("Alice was invited, joined, was removed, was invited, and joined")).toBeVisible(); + }); + + test("should knock into the room then knock is approved and user joins the room then user is banned/unbanned and joins again", async ({ + page, + app, + user, + bot, + room, + }) => { + await app.viewRoomById(room.roomId); + + const roomPreviewBar = page.locator(".mx_RoomPreviewBar"); + await roomPreviewBar.getByRole("button", { name: "Join the discussion" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible(); + await expect(roomPreviewBar.getByRole("textbox")).toBeVisible(); + await roomPreviewBar.getByRole("button", { name: "Request access" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible(); + + // Knocked room should appear in Rooms + await expect( + page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + ).toBeVisible(); + + // bot waits for knock request from Alice + await waitForRoom(page, bot, room.roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "m.room.member" && + e.getContent()?.membership === "knock" && + e.getContent()?.displayname === "Alice", + ); + }); + + // bot invites Alice + await bot.inviteUser(room.roomId, user.userId); + + await expect( + page.getByRole("group", { name: "Invites" }).getByRole("treeitem", { name: "Cybersecurity" }), + ).toBeVisible(); + + // Alice have to accept invitation in order to join the room. + // It will be not needed when homeserver implements auto accept knock requests. + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + await expect( + page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + ).toBeVisible(); + + await expect(page.getByText("Alice joined the room")).toBeVisible(); + + // bot bans Alice + await bot.ban(room.roomId, user.userId); + + await expect( + page.locator(".mx_RoomPreviewBar").getByText("You were banned from Cybersecurity by Bob"), + ).toBeVisible(); + + // bot unbans Alice + await bot.unban(room.roomId, user.userId); + + await roomPreviewBar.getByRole("button", { name: "Re-join" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join Cybersecurity?" })).toBeVisible(); + await roomPreviewBar.getByRole("button", { name: "Request access" }).click(); + + // bot waits for knock request from Alice + await waitForRoom(page, bot, room.roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "m.room.member" && + e.getContent()?.membership === "knock" && + e.getContent()?.displayname === "Alice", + ); + }); + + // bot invites Alice + await bot.inviteUser(room.roomId, user.userId); + + // Alice have to accept invitation in order to join the room. + // It will be not needed when homeserver implements auto accept knock requests. + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + await expect( + page.getByText("Alice was invited, joined, was banned, was unbanned, was invited, and joined"), + ).toBeVisible(); + }); + + test("should knock into the room and knock is cancelled by user himself", async ({ page, app, bot, room }) => { + await app.viewRoomById(room.roomId); + + const roomPreviewBar = page.locator(".mx_RoomPreviewBar"); + await roomPreviewBar.getByRole("button", { name: "Join the discussion" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible(); + await expect(roomPreviewBar.getByRole("textbox")).toBeVisible(); + await roomPreviewBar.getByRole("button", { name: "Request access" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible(); + + // Knocked room should appear in Rooms + page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }); + + await roomPreviewBar.getByRole("button", { name: "Cancel request" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join Cybersecurity?" })).toBeVisible(); + await expect(roomPreviewBar.getByRole("button", { name: "Request access" })).toBeVisible(); + + await expect( + page.getByRole("group", { name: "Historical" }).getByRole("treeitem", { name: "Cybersecurity" }), + ).toBeVisible(); + }); + + test("should knock into the room then knock is cancelled by another user and room is forgotten", async ({ + page, + app, + user, + bot, + room, + }) => { + await app.viewRoomById(room.roomId); + + const roomPreviewBar = page.locator(".mx_RoomPreviewBar"); + await roomPreviewBar.getByRole("button", { name: "Join the discussion" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible(); + await expect(roomPreviewBar.getByRole("textbox")).toBeVisible(); + await roomPreviewBar.getByRole("button", { name: "Request access" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible(); + + // Knocked room should appear in Rooms + await expect( + page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + ).toBeVisible(); + + // bot waits for knock request from Alice + await waitForRoom(page, bot, room.roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "m.room.member" && + e.getContent()?.membership === "knock" && + e.getContent()?.displayname === "Alice", + ); + }); + + // bot kicks Alice + await bot.kick(room.roomId, user.userId); + + // Room should stay in Rooms and have red badge when knock is denied + await expect( + page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity", exact: true }), + ).not.toBeVisible(); + await expect( + page + .getByRole("group", { name: "Rooms" }) + .getByRole("treeitem", { name: "Cybersecurity 1 unread mention." }), + ).toBeVisible(); + + await expect(roomPreviewBar.getByRole("heading", { name: "You have been denied access" })).toBeVisible(); + await roomPreviewBar.getByRole("button", { name: "Forget this room" }).click(); + + // Room should disappear from the list completely when forgotten + // Should be enabled when issue is fixed: https://github.com/vector-im/element-web/issues/26195 + // await expect(page.getByRole("treeitem", { name: /Cybersecurity/ })).not.toBeVisible(); + }); + + test("should knock into the public knock room via spotlight", async ({ page, app, bot, room }) => { + await bot.setRoomDirectoryVisibility(room.roomId, "public" as Visibility); + + const spotlightDialog = await app.openSpotlight(); + await spotlightDialog.filter(Filter.PublicRooms); + await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); + await spotlightDialog.results.nth(0).click(); + + const roomPreviewBar = page.locator(".mx_RoomPreviewBar"); + await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible(); + await expect(roomPreviewBar.getByRole("textbox")).toBeVisible(); + await roomPreviewBar.getByRole("button", { name: "Request access" }).click(); + await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible(); + }); +}); diff --git a/playwright/e2e/knock/manage-knocks.spec.ts b/playwright/e2e/knock/manage-knocks.spec.ts new file mode 100644 index 000000000000..3fb5c685517f --- /dev/null +++ b/playwright/e2e/knock/manage-knocks.spec.ts @@ -0,0 +1,118 @@ +/* +Copyright 2023 Mikhail Aheichyk +Copyright 2023 Nordeck IT + Consulting GmbH. +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; +import { waitForRoom } from "../utils"; + +test.describe("Manage Knocks", () => { + test.use({ + displayName: "Alice", + labsFlags: ["feature_ask_to_join"], + botCreateOpts: { + displayName: "Bob", + }, + room: async ({ app, user }, use) => { + const roomId = await app.client.createRoom({ + name: "Cybersecurity", + initial_state: [ + { + type: "m.room.join_rules", + content: { + join_rule: "knock", + }, + state_key: "", + }, + ], + }); + await app.viewRoomById(roomId); + await use({ roomId }); + }, + }); + + test("should approve knock using bar", async ({ page, bot, room }) => { + await bot.knockRoom(room.roomId); + + const roomKnocksBar = page.locator(".mx_RoomKnocksBar"); + await expect(roomKnocksBar.getByRole("heading", { name: "Asking to join" })).toBeVisible(); + await expect(roomKnocksBar.getByText(/^Bob/)).toBeVisible(); + await roomKnocksBar.getByRole("button", { name: "Approve" }).click(); + + await expect(roomKnocksBar).not.toBeVisible(); + + await expect(page.getByText("Alice invited Bob")).toBeVisible(); + }); + + test("should deny knock using bar", async ({ page, app, bot, room }) => { + bot.knockRoom(room.roomId); + + const roomKnocksBar = page.locator(".mx_RoomKnocksBar"); + await expect(roomKnocksBar.getByRole("heading", { name: "Asking to join" })).toBeVisible(); + await expect(roomKnocksBar.getByText(/^Bob/)).toBeVisible(); + await roomKnocksBar.getByRole("button", { name: "Deny" }).click(); + + await expect(roomKnocksBar).not.toBeVisible(); + + // Should receive Bob's "m.room.member" with "leave" membership when access is denied + await waitForRoom(page, app.client, room.roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "m.room.member" && + e.getContent()?.membership === "leave" && + e.getContent()?.displayname === "Bob", + ); + }); + }); + + test("should approve knock using people tab", async ({ page, app, bot, room }) => { + await bot.knockRoom(room.roomId, { reason: "Hello, can I join?" }); + + await app.settings.openRoomSettings("People"); + + const settingsGroup = page.getByRole("group", { name: "Asking to join" }); + await expect(settingsGroup.getByText(/^Bob/)).toBeVisible(); + await expect(settingsGroup.getByText("Hello, can I join?")).toBeVisible(); + await settingsGroup.getByRole("button", { name: "Approve" }).click(); + await expect(settingsGroup.getByText(/^Bob/)).not.toBeVisible(); + + await expect(page.getByText("Alice invited Bob")).toBeVisible(); + }); + + test("should deny knock using people tab", async ({ page, app, bot, room }) => { + await bot.knockRoom(room.roomId, { reason: "Hello, can I join?" }); + + await app.settings.openRoomSettings("People"); + + const settingsGroup = page.getByRole("group", { name: "Asking to join" }); + await expect(settingsGroup.getByText(/^Bob/)).toBeVisible(); + await expect(settingsGroup.getByText("Hello, can I join?")).toBeVisible(); + await settingsGroup.getByRole("button", { name: "Deny" }).click(); + await expect(settingsGroup.getByText(/^Bob/)).not.toBeVisible(); + + // Should receive Bob's "m.room.member" with "leave" membership when access is denied + await waitForRoom(page, app.client, room.roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "m.room.member" && + e.getContent()?.membership === "leave" && + e.getContent()?.displayname === "Bob", + ); + }); + }); +}); diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts new file mode 100644 index 000000000000..8b8158981362 --- /dev/null +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -0,0 +1,137 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Bot } from "../../pages/bot"; +import type { Locator, Page } from "@playwright/test"; +import type { ElementAppPage } from "../../pages/ElementAppPage"; +import { test, expect } from "../../element-web-test"; + +test.describe("Lazy Loading", () => { + const charlies: Bot[] = []; + + test.use({ + displayName: "Alice", + botCreateOpts: { displayName: "Bob" }, + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests + }); + }); + + test.beforeEach(async ({ page, homeserver, user, bot }) => { + 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); + } + }); + + const name = "Lazy Loading Test"; + const alias = "#lltest:localhost"; + const charlyMsg1 = "hi bob!"; + const charlyMsg2 = "how's it going??"; + let roomId: string; + + async function setupRoomWithBobAliceAndCharlies(page: Page, app: ElementAppPage, bob: Bot, charlies: Bot[]) { + const visibility = await page.evaluate(() => (window as any).matrixcs.Visibility.Public); + roomId = await bob.createRoom({ + name, + room_alias_name: "lltest", + visibility, + }); + + await Promise.all(charlies.map((bot) => bot.joinRoom(alias))); + for (const charly of charlies) { + await charly.sendMessage(roomId, charlyMsg1); + } + for (const charly of charlies) { + await charly.sendMessage(roomId, charlyMsg2); + } + + for (let i = 20; i >= 1; --i) { + await bob.sendMessage(roomId, `I will only say this ${i} time(s)!`); + } + await app.client.joinRoom(alias); + await app.viewRoomByName(name); + } + + async function checkPaginatedDisplayNames(app: ElementAppPage, charlies: Bot[]) { + await app.timeline.scrollToTop(); + for (const charly of charlies) { + await expect(await app.timeline.findEventTile(charly.credentials.displayName, charlyMsg1)).toBeAttached(); + await expect(await app.timeline.findEventTile(charly.credentials.displayName, charlyMsg2)).toBeAttached(); + } + } + + async function openMemberlist(page: Page): Promise { + await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Room info" }).click(); + await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "People" }).click(); // \d represents the number of the room members + } + + function getMemberInMemberlist(page: Page, name: string): Locator { + return page.locator(".mx_MemberList .mx_EntityTile_name").filter({ hasText: name }); + } + + async function checkMemberList(page: Page, charlies: Bot[]) { + await expect(getMemberInMemberlist(page, "Alice")).toBeAttached(); + await expect(getMemberInMemberlist(page, "Bob")).toBeAttached(); + for (const charly of charlies) { + await expect(getMemberInMemberlist(page, charly.credentials.displayName)).toBeAttached(); + } + } + + async function checkMemberListLacksCharlies(page: Page, charlies: Bot[]) { + for (const charly of charlies) { + await expect(getMemberInMemberlist(page, charly.credentials.displayName)).not.toBeAttached(); + } + } + + async function joinCharliesWhileAliceIsOffline(page: Page, app: ElementAppPage, charlies: Bot[]) { + await app.client.network.goOffline(); + for (const charly of charlies) { + await charly.joinRoom(alias); + } + for (let i = 20; i >= 1; --i) { + await charlies[0].sendMessage(roomId, "where is charly?"); + } + await app.client.network.goOnline(); + await app.client.waitForNextSync(); + } + + test("should handle lazy loading properly even when offline", async ({ page, app, bot }) => { + test.slow(); + const charly1to5 = charlies.slice(0, 5); + const charly6to10 = charlies.slice(5); + + // Set up room with alice, bob & charlies 1-5 + await setupRoomWithBobAliceAndCharlies(page, app, bot, charly1to5); + // Alice should see 2 messages from every charly with the correct display name + await checkPaginatedDisplayNames(app, charly1to5); + + await openMemberlist(page); + await checkMemberList(page, charly1to5); + await joinCharliesWhileAliceIsOffline(page, app, charly6to10); + await checkMemberList(page, charly6to10); + + for (const charly of charlies) { + await charly.evaluate((client, roomId) => client.leave(roomId), roomId); + } + + await checkMemberListLacksCharlies(page, charlies); + }); +}); diff --git a/playwright/e2e/permalinks/permalinks.spec.ts b/playwright/e2e/permalinks/permalinks.spec.ts new file mode 100644 index 000000000000..6b3a10a4d66e --- /dev/null +++ b/playwright/e2e/permalinks/permalinks.spec.ts @@ -0,0 +1,110 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Locator } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; + +const room1Name = "Room 1"; +const room2Name = "Room 2"; +const unknownRoomAlias = "#unknownroom:example.com"; +const permalinkPrefix = "https://matrix.to/#/"; + +const getPill = (locator: Locator, label: string) => { + return locator.locator(".mx_Pill_text", { hasText: new RegExp("^" + label + "$", "g") }); +}; + +test.describe("permalinks", () => { + test.use({ + displayName: "Alice", + }); + + test("shoud render permalinks as expected", async ({ page, app, user, homeserver }) => { + const bob = new Bot(page, homeserver, { displayName: "Bob" }); + const charlotte = new Bot(page, homeserver, { displayName: "Charlotte" }); + await bob.prepareClient(); + await charlotte.prepareClient(); + + // We don't use a bot for danielle as we want a stable MXID. + const danielleId = "@danielle:localhost"; + + const room1Id = await app.client.createRoom({ name: room1Name }); + const room2Id = await app.client.createRoom({ name: room2Name }); + + await app.viewRoomByName(room1Name); + + await app.client.inviteUser(room1Id, bob.credentials.userId); + await app.client.inviteUser(room2Id, charlotte.credentials.userId); + + await app.client.sendMessage(room1Id, "At room mention: @room"); + + await app.client.sendMessage(room1Id, `Permalink to Room 2: ${permalinkPrefix}${room2Id}`); + await app.client.sendMessage( + room1Id, + `Permalink to an unknown room alias: ${permalinkPrefix}${unknownRoomAlias}`, + ); + + const event1Response = await bob.sendMessage(room1Id, "Hello"); + await app.client.sendMessage( + room1Id, + `Permalink to a message in the same room: ${permalinkPrefix}${room1Id}/${event1Response.event_id}`, + ); + + const event2Response = await charlotte.sendMessage(room2Id, "Hello"); + await app.client.sendMessage( + room1Id, + `Permalink to a message in another room: ${permalinkPrefix}${room2Id}/${event2Response.event_id}`, + ); + + await app.client.sendMessage(room1Id, `Permalink to an unknown message: ${permalinkPrefix}${room1Id}/$abc123`); + + await app.client.sendMessage( + room1Id, + `Permalink to a user in the room: ${permalinkPrefix}${bob.credentials.userId}`, + ); + await app.client.sendMessage( + room1Id, + `Permalink to a user in another room: ${permalinkPrefix}${charlotte.credentials.userId}`, + ); + await app.client.sendMessage( + room1Id, + `Permalink to a user with whom alice doesn't share a room: ${permalinkPrefix}${danielleId}`, + ); + + const timeline = page.locator(".mx_RoomView_timeline"); + getPill(timeline, "@room"); + + getPill(timeline, room2Name); + getPill(timeline, unknownRoomAlias); + + getPill(timeline, "Message from Bob"); + getPill(timeline, `Message in ${room2Name}`); + getPill(timeline, "Message"); + + getPill(timeline, "Bob"); + getPill(timeline, "Charlotte"); + // This is the permalink to Danielle's profile. It should only display the MXID + // because the profile is unknown (not sharing any room with Danielle). + getPill(timeline, danielleId); + + await expect(timeline).toMatchScreenshot("permalink-rendering.png", { + mask: [ + // Exclude timestamps from the snapshot, for consistency. + page.locator(".mx_MessageTimestamp"), + ], + }); + }); +}); diff --git a/playwright/e2e/polls/pollHistory.spec.ts b/playwright/e2e/polls/pollHistory.spec.ts new file mode 100644 index 000000000000..458bb544c7cd --- /dev/null +++ b/playwright/e2e/polls/pollHistory.spec.ts @@ -0,0 +1,149 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { test, expect } from "../../element-web-test"; +import type { Page } from "@playwright/test"; +import type { Bot } from "../../pages/bot"; +import type { Client } from "../../pages/client"; + +test.describe("Poll history", () => { + type CreatePollOptions = { + title: string; + options: { + "id": string; + "org.matrix.msc1767.text": string; + }[]; + }; + const createPoll = async (createOptions: CreatePollOptions, roomId: string, client: Client) => { + return client.sendEvent(roomId, null, "org.matrix.msc3381.poll.start", { + "org.matrix.msc3381.poll.start": { + question: { + "org.matrix.msc1767.text": createOptions.title, + "body": createOptions.title, + "msgtype": "m.text", + }, + kind: "org.matrix.msc3381.poll.disclosed", + max_selections: 1, + answers: createOptions.options, + }, + "org.matrix.msc1767.text": "poll fallback text", + }); + }; + + const botVoteForOption = async (bot: Bot, roomId: string, pollId: string, optionId: string): Promise => { + // We can't use the js-sdk types for this stuff directly, so manually construct the event. + await bot.sendEvent(roomId, null, "org.matrix.msc3381.poll.response", { + "m.relates_to": { + rel_type: "m.reference", + event_id: pollId, + }, + "org.matrix.msc3381.poll.response": { + answers: [optionId], + }, + }); + }; + + const endPoll = async (bot: Bot, roomId: string, pollId: string): Promise => { + // We can't use the js-sdk types for this stuff directly, so manually construct the event. + await bot.sendEvent(roomId, null, "org.matrix.msc3381.poll.end", { + "m.relates_to": { + rel_type: "m.reference", + event_id: pollId, + }, + "org.matrix.msc1767.text": "The poll has ended", + }); + }; + + async function openPollHistory(page: Page): Promise { + await page.getByRole("button", { name: "Room info" }).click(); + await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "Poll history" }).click(); + } + + test.use({ + displayName: "Tom", + botCreateOpts: { displayName: "BotBob" }, + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + // Collapse left panel for these tests + window.localStorage.setItem("mx_lhs_size", "0"); + }); + }); + + test("Should display active and past polls", async ({ page, app, user, bot }) => { + const pollParams1 = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe"].map((option) => ({ + "id": option, + "org.matrix.msc1767.text": option, + })), + }; + + const pollParams2 = { + title: "Which way", + options: ["Left", "Right"].map((option) => ({ + "id": option, + "org.matrix.msc1767.text": option, + })), + }; + + const roomId = await app.client.createRoom({}); + + await app.client.inviteUser(roomId, bot.credentials.userId); + await page.goto("/#/room/" + roomId); + // wait until Bob joined + await expect(page.getByText("BotBob joined the room")).toBeAttached(); + + // active poll + const { event_id: pollId1 } = await createPoll(pollParams1, roomId, bot); + await botVoteForOption(bot, roomId, pollId1, pollParams1.options[1].id); + + // ended poll + const { event_id: pollId2 } = await createPoll(pollParams2, roomId, bot); + await botVoteForOption(bot, roomId, pollId2, pollParams1.options[1].id); + await endPoll(bot, roomId, pollId2); + + await openPollHistory(page); + + // these polls are also in the timeline + // focus on the poll history dialog + const dialog = page.locator(".mx_Dialog"); + + // active poll is in active polls list + // open poll detail + await dialog.getByText(pollParams1.title).click(); + await dialog.getByText("Yes").click(); + // vote in the poll + await expect(dialog.getByTestId("totalVotes").getByText("Based on 2 votes")).toBeAttached(); + // navigate back to list + await dialog.locator(".mx_PollHistory_header").getByRole("button", { name: "Active polls" }).click(); + + // go to past polls list + await dialog.getByText("Past polls").click(); + + await expect(dialog.getByText(pollParams2.title)).toBeAttached(); + + // end poll1 while dialog is open + await endPoll(bot, roomId, pollId1); + + await expect(dialog.getByText(pollParams2.title)).toBeAttached(); + await expect(dialog.getByText(pollParams1.title)).toBeAttached(); + dialog.getByText("Active polls").click(); + + // no more active polls + await expect(page.getByText("There are no active polls in this room")).toBeAttached(); + }); +}); diff --git a/playwright/e2e/polls/polls.spec.ts b/playwright/e2e/polls/polls.spec.ts new file mode 100644 index 000000000000..c4a8ae1bbe53 --- /dev/null +++ b/playwright/e2e/polls/polls.spec.ts @@ -0,0 +1,333 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { Layout } from "../../../src/settings/enums/Layout"; +import type { Locator, Page } from "@playwright/test"; + +test.describe("Polls", () => { + type CreatePollOptions = { + title: string; + options: string[]; + }; + const createPoll = async (page: Page, { title, options }: CreatePollOptions) => { + if (options.length < 2) { + throw new Error("Poll must have at least two options"); + } + const dialog = page.locator(".mx_PollCreateDialog"); + await dialog.getByRole("textbox", { name: "Question or topic" }).fill(title); + for (const [index, value] of options.entries()) { + const optionIdLocator = dialog.locator(`#pollcreate_option_${index}`); + // click 'add option' button if needed + if ((await optionIdLocator.count()) === 0) { + const button = dialog.getByRole("button", { name: "Add option" }); + await button.scrollIntoViewIfNeeded(); + await button.click(); + } + await optionIdLocator.scrollIntoViewIfNeeded(); + await optionIdLocator.fill(value); + } + await page.locator(".mx_Dialog").getByRole("button", { name: "Create Poll" }).click(); + }; + + const getPollTile = (page: Page, pollId: string, optLocator?: Locator): Locator => { + return (optLocator ?? page).locator(`.mx_EventTile[data-scroll-tokens="${pollId}"]`); + }; + + const getPollOption = (page: Page, pollId: string, optionText: string, optLocator?: Locator): Locator => { + return getPollTile(page, pollId, optLocator) + .locator(".mx_PollOption .mx_StyledRadioButton") + .filter({ hasText: optionText }); + }; + + const expectPollOptionVoteCount = async ( + page: Page, + pollId: string, + optionText: string, + votes: number, + optLocator?: Locator, + ): Promise => { + await expect( + getPollOption(page, pollId, optionText, optLocator).locator(".mx_PollOption_optionVoteCount"), + ).toContainText(`${votes} vote`); + }; + + const botVoteForOption = async ( + page: Page, + bot: Bot, + roomId: string, + pollId: string, + optionText: string, + ): Promise => { + const locator = getPollOption(page, pollId, optionText); + const optionId = await locator.first().getByRole("radio").getAttribute("value"); + + // We can't use the js-sdk types for this stuff directly, so manually construct the event. + await bot.sendEvent(roomId, null, "org.matrix.msc3381.poll.response", { + "m.relates_to": { + rel_type: "m.reference", + event_id: pollId, + }, + "org.matrix.msc3381.poll.response": { + answers: [optionId], + }, + }); + }; + + test.use({ + displayName: "Tom", + botCreateOpts: { displayName: "BotBob" }, + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + // Collapse left panel for these tests + window.localStorage.setItem("mx_lhs_size", "0"); + }); + }); + + test("should be creatable and votable", async ({ page, app, bot, user }) => { + const roomId: string = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await page.goto("/#/room/" + roomId); + // wait until Bob joined + await expect(page.getByText("BotBob joined the room")).toBeAttached(); + + const locator = await app.openMessageComposerOptions(); + await locator.getByRole("menuitem", { name: "Poll" }).click(); + + // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 + //cy.get(".mx_CompoundDialog").percySnapshotElement("Polls Composer"); + + const pollParams = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe?"], + }; + await createPoll(page, pollParams); + + // Wait for message to send, get its ID and save as @pollId + const pollId = await page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: pollParams.title }) + .getAttribute("data-scroll-tokens"); + await expect(getPollTile(page, pollId)).toMatchScreenshot("Polls_Timeline_tile_no_votes.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + + // Bot votes 'Maybe' in the poll + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]); + + // no votes shown until I vote, check bots vote has arrived + await expect( + page.locator(".mx_MPollBody_totalVotes").getByText("1 vote cast. Vote to see the results"), + ).toBeAttached(); + + // vote 'Maybe' + await getPollOption(page, pollId, pollParams.options[2]).click(); + // both me and bot have voted Maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2); + + // change my vote to 'Yes' + await getPollOption(page, pollId, pollParams.options[0]).click(); + + // 1 vote for yes + await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1); + // 1 vote for maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 1); + + // Bot updates vote to 'No' + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[1]); + + // 1 vote for yes + await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1); + // 1 vote for no + await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1); + // 0 for maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 0); + }); + + test("should be editable from context menu if no votes have been cast", async ({ page, app, user, bot }) => { + const roomId: string = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await page.goto("/#/room/" + roomId); + + const locator = await app.openMessageComposerOptions(); + await locator.getByRole("menuitem", { name: "Poll" }).click(); + + const pollParams = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe"], + }; + await createPoll(page, pollParams); + + // Wait for message to send, get its ID and save as @pollId + const pollId = await page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: pollParams.title }) + .getAttribute("data-scroll-tokens"); + + // Open context menu + await getPollTile(page, pollId).click({ button: "right" }); + + // Select edit item + await page.getByRole("menuitem", { name: "Edit" }).click(); + + // Expect poll editing dialog + await expect(page.locator(".mx_PollCreateDialog")).toBeAttached(); + }); + + test("should not be editable from context menu if votes have been cast", async ({ page, app, user, bot }) => { + const roomId: string = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await page.goto("/#/room/" + roomId); + + const locator = await app.openMessageComposerOptions(); + await locator.getByRole("menuitem", { name: "Poll" }).click(); + + const pollParams = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe"], + }; + await createPoll(page, pollParams); + + // Wait for message to send, get its ID and save as @pollId + const pollId = await page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: pollParams.title }) + .getAttribute("data-scroll-tokens"); + + // Bot votes 'Maybe' in the poll + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]); + + // wait for bot's vote to arrive + await expect(page.locator(".mx_MPollBody_totalVotes")).toContainText("1 vote cast"); + + // Open context menu + await getPollTile(page, pollId).click({ button: "right" }); + + // Select edit item + await page.getByRole("menuitem", { name: "Edit" }).click(); + + // Expect poll editing dialog + await expect(page.locator(".mx_ErrorDialog")).toBeAttached(); + }); + + test("should be displayed correctly in thread panel", async ({ page, app, user, bot, homeserver }) => { + const botCharlie = new Bot(page, homeserver, { displayName: "BotCharlie" }); + await botCharlie.prepareClient(); + + const roomId: string = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await app.client.inviteUser(roomId, botCharlie.credentials.userId); + await page.goto("/#/room/" + roomId); + + // wait until the bots joined + await expect(page.getByText("BotBob and one other were invited and joined")).toBeAttached({ timeout: 10000 }); + + const locator = await app.openMessageComposerOptions(); + await locator.getByRole("menuitem", { name: "Poll" }).click(); + + const pollParams = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe"], + }; + await createPoll(page, pollParams); + + // Wait for message to send, get its ID and save as @pollId + const pollId = await page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: pollParams.title }) + .getAttribute("data-scroll-tokens"); + + // Bob starts thread on the poll + await bot.sendMessage( + roomId, + { + body: "Hello there", + msgtype: "m.text", + }, + pollId, + ); + + // open the thread summary + await page.getByRole("button", { name: "Open thread" }).click(); + + // Bob votes 'Maybe' in the poll + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]); + + // Charlie votes 'No' + await botVoteForOption(page, botCharlie, roomId, pollId, pollParams.options[1]); + + // no votes shown until I vote, check votes have arrived in main tl + await expect( + page + .locator(".mx_RoomView_body .mx_MPollBody_totalVotes") + .getByText("2 votes cast. Vote to see the results"), + ).toBeAttached(); + + // and thread view + await expect( + page.locator(".mx_ThreadView .mx_MPollBody_totalVotes").getByText("2 votes cast. Vote to see the results"), + ).toBeAttached(); + + // Take snapshots of poll on ThreadView + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']").first()).toBeVisible(); + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("ThreadView_with_a_poll_on_bubble_layout.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='group']").first()).toBeVisible(); + + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("ThreadView_with_a_poll_on_group_layout.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + + const roomViewLocator = page.locator(".mx_RoomView_body"); + // vote 'Maybe' in the main timeline poll + await getPollOption(page, pollId, pollParams.options[2], roomViewLocator).click(); + // both me and bob have voted Maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2, roomViewLocator); + + const threadViewLocator = page.locator(".mx_ThreadView"); + // votes updated in thread view too + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2, threadViewLocator); + // change my vote to 'Yes' + await getPollOption(page, pollId, pollParams.options[0], threadViewLocator).click(); + + // Bob updates vote to 'No' + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[1]); + + // me: yes, bob: no, charlie: no + const expectVoteCounts = async (optLocator: Locator) => { + // I voted yes + await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1, optLocator); + // Bob and Charlie voted no + await expectPollOptionVoteCount(page, pollId, pollParams.options[1], 2, optLocator); + // 0 for maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 0, optLocator); + }; + + // check counts are correct in main timeline tile + await expectVoteCounts(page.locator(".mx_RoomView_body")); + + // and in thread view tile + await expectVoteCounts(page.locator(".mx_ThreadView")); + }); +}); diff --git a/playwright/e2e/presence/presence.spec.ts b/playwright/e2e/presence/presence.spec.ts new file mode 100644 index 000000000000..62c14b7ed5b4 --- /dev/null +++ b/playwright/e2e/presence/presence.spec.ts @@ -0,0 +1,66 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Presence tests", () => { + test.use({ + displayName: "Janet", + botCreateOpts: { displayName: "Bob" }, + }); + + test.describe("bob unreachable", () => { + test("renders unreachable presence state correctly", async ({ page, app, user, bot: bob }) => { + await app.client.createRoom({ name: "My Room", invite: [bob.credentials.userId] }); + await app.viewRoomByName("My Room"); + + await bob.evaluate(async (client) => { + client.stopClient(); + }); + + await page.route( + `**/sync*`, + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + json: { + ...(await response.json()), + presence: { + events: [ + { + type: "m.presence", + sender: bob.credentials.userId, + content: { + presence: "io.element.unreachable", + currently_active: false, + }, + }, + ], + }, + }, + }); + }, + { times: 1 }, + ); + await app.client.createRoom({}); // trigger sync + + await page.getByRole("button", { name: "Room info" }).click(); + await page.locator(".mx_RightPanel").getByText("People").click(); + await expect(page.locator(".mx_EntityTile_unreachable")).toContainText("Bob"); + await expect(page.locator(".mx_EntityTile_unreachable")).toContainText("User's server unreachable"); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/high-level.spec.ts b/playwright/e2e/read-receipts/high-level.spec.ts index ce9e865cc652..897e752ac45f 100644 --- a/playwright/e2e/read-receipts/high-level.spec.ts +++ b/playwright/e2e/read-receipts/high-level.spec.ts @@ -157,8 +157,7 @@ test.describe("Read receipts", () => { }); test.describe("Paging up", () => { - // XXX: Fails because flaky test https://github.com/vector-im/element-web/issues/26437 - test.skip("Paging up through old messages after a room is read leaves the room read", async ({ + test("Paging up through old messages after a room is read leaves the room read", async ({ page, roomAlpha: room1, roomBeta: room2, @@ -256,10 +255,6 @@ test.describe("Read receipts", () => { msg, }) => { test.slow(); - test.skip( - cryptoBackend === "rust", - "Flaky with rust crypto - see https://github.com/vector-im/element-web/issues/26539", - ); // Given lots of messages in threads that are unread await util.goTo(room1); @@ -297,8 +292,7 @@ test.describe("Read receipts", () => { await util.assertUnreadThread("Root2"); await util.assertUnreadThread("Root3"); }); - // XXX: fails because flaky: https://github.com/vector-im/element-web/issues/26331 - test.skip("Looking in thread view to find old threads that were never read makes the room unread", async ({ + test("Looking in thread view to find old threads that were never read makes the room unread", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -348,10 +342,6 @@ test.describe("Read receipts", () => { msg, }) => { test.slow(); - test.skip( - cryptoBackend === "rust", - "Flaky with rust crypto - see https://github.com/vector-im/element-web/issues/26341", - ); // Given lots of messages in threads that are unread but I marked as read on a main timeline message await util.goTo(room1); @@ -385,8 +375,7 @@ test.describe("Read receipts", () => { await util.assertReadThread("Root2"); await util.assertReadThread("Root3"); }); - // XXX: fails because we see a dot instead of an unread number - probably the server and client disagree - test.skip("After marking room as read based on a thread message, opening threads view to find old threads that were never read leaves the room read", async ({ + test("After marking room as read based on a thread message, opening threads view to find old threads that were never read leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -426,7 +415,43 @@ test.describe("Read receipts", () => { }); test.describe("Room list order", () => { - test.fixme("Rooms with unread threads appear at the top of room list if 'unread first' is selected", () => {}); + test("Rooms with unread messages appear at the top of room list if 'unread first' is selected", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + page, + }) => { + await util.goTo(room2); + + // Display the unread first room + await util.toggleRoomUnreadOrder(); + await util.receiveMessages(room1, ["Msg1"]); + await page.reload(); + + // Room 1 has an unread message and should be displayed first + await util.assertRoomListOrder([room1, room2]); + }); + + test("Rooms with unread threads appear at the top of room list if 'unread first' is selected", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room2); + await util.receiveMessages(room1, ["Msg1"]); + await util.markAsRead(room1); + await util.assertRead(room1); + + // Display the unread first room + await util.toggleRoomUnreadOrder(); + await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]); + await util.saveAndReload(); + + // Room 1 has an unread message and should be displayed first + await util.assertRoomListOrder([room1, room2]); + }); }); test.describe("Notifications", () => { diff --git a/playwright/e2e/read-receipts/index.ts b/playwright/e2e/read-receipts/index.ts index efa93dc9d42c..6b9a8381d271 100644 --- a/playwright/e2e/read-receipts/index.ts +++ b/playwright/e2e/read-receipts/index.ts @@ -68,7 +68,11 @@ export const test = base.extend<{ * which finds a message and then constructs a reply to it. */ export class MessageBuilder { - constructor(private page: Page, private app: ElementAppPage, private helpers: Helpers) {} + constructor( + private page: Page, + private app: ElementAppPage, + private helpers: Helpers, + ) {} /** * Map of message content -> event. @@ -155,10 +159,19 @@ export class MessageBuilder { public async getContent(room: JSHandle): Promise> { const ev = await this.messageFinder.getMessage(room, targetMessage, true); return ev.evaluate((ev, newMessage) => { + const threadRel = + ev.getRelation()?.rel_type === "m.thread" + ? { + rel_type: "m.thread", + event_id: ev.getRelation().event_id, + } + : {}; + return { "msgtype": "m.text", "body": newMessage, "m.relates_to": { + ...threadRel, "m.in_reply_to": { event_id: ev.getId(), }, @@ -325,7 +338,11 @@ export abstract class BotActionSpec { export type Message = string | MessageContentSpec | BotActionSpec; class Helpers { - constructor(private page: Page, private app: ElementAppPage, private bot: Bot) {} + constructor( + private page: Page, + private app: ElementAppPage, + private bot: Bot, + ) {} /** * Use the supplied client to send messages or perform actions as specified by @@ -361,6 +378,15 @@ class Helpers { await this.app.viewRoomByName(typeof room === "string" ? room : room.name); } + /** + * Expand the message with the supplied index in the timeline. + * @param index + */ + async openCollapsedMessage(index: number) { + const button = this.page.locator(".mx_GenericEventListSummary_toggle"); + await button.nth(index).click(); + } + /** * Click the thread with the supplied content in the thread root to open it in * the Threads panel. @@ -572,6 +598,37 @@ class Helpers { async receiveMessages(room: string | { name: string }, messages: Message[]) { await this.sendMessageAsClient(this.bot, room, messages); } + + /** + * Open the room list menu + */ + async toggleRoomListMenu() { + const tile = this.getRoomListTile("Rooms"); + await tile.hover(); + const button = tile.getByLabel("List options"); + await button.click(); + } + + /** + * Toggle the `Show rooms with unread messages first` option for the room list + */ + async toggleRoomUnreadOrder() { + await this.toggleRoomListMenu(); + await this.page.getByText("Show rooms with unread messages first").click(); + // Close contextual menu + await this.page.locator(".mx_ContextualMenu_background").click(); + } + + /** + * Assert that the room list is ordered as expected + * @param rooms + */ + async assertRoomListOrder(rooms: Array<{ name: string }>) { + const roomList = this.page.locator(".mx_RoomTile_title"); + for (const [i, room] of rooms.entries()) { + await expect(roomList.nth(i)).toHaveText(room.name); + } + } } /** diff --git a/playwright/e2e/read-receipts/new-messages.spec.ts b/playwright/e2e/read-receipts/new-messages.spec.ts index 3f0e40ac9545..14434709cead 100644 --- a/playwright/e2e/read-receipts/new-messages.spec.ts +++ b/playwright/e2e/read-receipts/new-messages.spec.ts @@ -55,8 +55,7 @@ test.describe("Read receipts", () => { // Then the room becomes read await util.assertRead(room2); }); - // XXX: fails (sometimes!) because the unread count stays high - test.skip("Reading an older message leaves the room unread", async ({ + test("Reading an older message leaves the room unread", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -164,27 +163,6 @@ test.describe("Read receipts", () => { // Then all messages are still read await util.assertRead(room2); }); - // XXX: fails because the room remains unread even though I sent a message - // Note: this test should not re-use the same MatrixClient - it - // should create a new one logged in as the same user. - test.skip("Me sending a message from a different client marks room as read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - app, - }) => { - // Given I have unread messages - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - - // When I send a new message from a different client - await util.sendMessageAsClient(app.client, room2, ["Msg2"]); - - // Then this room is marked as read - await util.assertRead(room2); - }); }); test.describe("in threads", () => { @@ -252,8 +230,7 @@ test.describe("Read receipts", () => { await util.assertReadThread("Msg1"); await util.assertRead(room2); }); - // XXX: Fails since migration to Playwright - test.skip("Reading an older thread message leaves the thread unread", async ({ + test("Reading an older thread message leaves the thread unread", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -268,13 +245,8 @@ test.describe("Read receipts", () => { await util.assertUnread(room2, 21); // When I read an older message in the thread - await msg.jumpTo(room2.name, "InThread0001", true); + await msg.jumpTo(room2.name, "InThread0000", true); await util.assertUnreadLessThan(room2, 21); - // TODO: for some reason, we can't find the first message - // "InThread0", so I am using the second here. Also, they appear - // out of order, with "InThread2" before "InThread1". Might be a - // clue to the sporadic reports we have had of messages going - // missing in threads? // Then the thread is still marked as unread await util.backToThreadsList(); @@ -499,8 +471,7 @@ test.describe("Read receipts", () => { await util.assertUnread(room2, 1); await util.assertUnreadThread("Msg1"); }); - // XXX: fails because we jump to the wrong place in the timeline - test.skip("Reading a thread root within the thread view marks it as read in the main timeline", async ({ + test("Reading a thread root within the thread view marks it as read in the main timeline", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -518,11 +489,12 @@ test.describe("Read receipts", () => { // When I jump to an old message and read the thread await msg.jumpTo(room2.name, "beforeThread0000"); + // When the thread is opened, the timeline is scrolled until the thread root reached the center await util.openThread("ThreadRoot"); // Then the thread root is marked as read in the main timeline, - // so there are only 30 left - the ones after the thread root. - await util.assertUnread(room2, 30); + // 30 remaining messages are unread - 7 messages are displayed under the thread root + await util.assertUnread(room2, 30 - 7); }); test("Creating a new thread based on a reply makes the room unread", async ({ roomAlpha: room1, diff --git a/playwright/e2e/read-receipts/reactions.spec.ts b/playwright/e2e/read-receipts/reactions.spec.ts index 30edb0298617..1063c7d19e49 100644 --- a/playwright/e2e/read-receipts/reactions.spec.ts +++ b/playwright/e2e/read-receipts/reactions.spec.ts @@ -119,8 +119,7 @@ test.describe("Read receipts", () => { // Then the room remains read await util.assertStillRead(room2); }); - // XXX: fails because the room is still "bold" even though the notification counts all disappear - test.skip("Marking a room as read after a reaction in a thread makes it read", async ({ + test("Marking a room as read after a reaction in a thread makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -142,8 +141,7 @@ test.describe("Read receipts", () => { // Then it becomes read await util.assertRead(room2); }); - // XXX: fails because the room is still "bold" even though the notification counts all disappear - test.skip("Reacting to a thread message after marking as read does not make the room unread", async ({ + test("Reacting to a thread message after marking as read does not make the room unread", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -167,7 +165,7 @@ test.describe("Read receipts", () => { // Then the room remains read await util.assertStillRead(room2); }); - test.skip("A room with a reaction to a threaded message is still unread after restart", async ({ + test("A room with a reaction to a threaded message is still unread after restart", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -329,8 +327,7 @@ test.describe("Read receipts", () => { // Then the room is still read await util.assertRead(room2); }); - // XXX: fails because the room is still "bold" even though the notification counts all disappear - test.skip("Reacting to a thread root after marking as read makes the room unread but not the thread", async ({ + test("Reacting to a thread root after marking as read makes the room unread but not the thread", async ({ page, roomAlpha: room1, roomBeta: room2, diff --git a/playwright/e2e/read-receipts/read-receipts.spec.ts b/playwright/e2e/read-receipts/read-receipts.spec.ts index f2bad62dd076..36f74e2c64e8 100644 --- a/playwright/e2e/read-receipts/read-receipts.spec.ts +++ b/playwright/e2e/read-receipts/read-receipts.spec.ts @@ -107,7 +107,12 @@ test.describe("Read receipts", () => { await page.goto(`/#/room/${selectedRoomId}`); }); - test("With sync accumulator, considers main thread and unthreaded receipts #24629", async ({ page, app, bot }) => { + // Disabled due to flakiness: https://github.com/element-hq/element-web/issues/26895 + test.skip("With sync accumulator, considers main thread and unthreaded receipts #24629", async ({ + page, + app, + bot, + }) => { // Details are in https://github.com/vector-im/element-web/issues/24629 // This proves we've fixed one of the "stuck unreads" issues. diff --git a/playwright/e2e/read-receipts/redactions.spec.ts b/playwright/e2e/read-receipts/redactions.spec.ts index 049724ba4f98..1b5751acbc1f 100644 --- a/playwright/e2e/read-receipts/redactions.spec.ts +++ b/playwright/e2e/read-receipts/redactions.spec.ts @@ -206,8 +206,7 @@ test.describe("Read receipts", () => { // Then the room is back to being read await util.assertRead(room2); }); - // XXX: fails because it flakes saying the room is unread when it should be read - test.skip("Redacting all unread messages makes the room read after restart", async ({ + test("Redacting all unread messages makes the room read after restart", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -277,8 +276,7 @@ test.describe("Read receipts", () => { // Then the room is still read await util.assertStillRead(room2); }); - // XXX: fails because flakes showing 2 unread instead of 1 - test.skip("A reply to a redacted message makes the room unread", async ({ + test("A reply to a redacted message makes the room unread", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -331,8 +329,7 @@ test.describe("Read receipts", () => { }); test.describe("in threads", () => { - // XXX: fails because it flakes saying the room is unread when it should be read - test.skip("Redacting the threaded message pointed to by my receipt leaves the room read", async ({ + test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -341,9 +338,9 @@ test.describe("Read receipts", () => { // Given I have some threads await util.goTo(room1); await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "ThreadMsg1"), - msg.threadedOff("Root", "ThreadMsg2"), + "Root1", + msg.threadedOff("Root1", "ThreadMsg1"), + msg.threadedOff("Root1", "ThreadMsg2"), "Root2", msg.threadedOff("Root2", "Root2->A"), ]); @@ -351,8 +348,8 @@ test.describe("Read receipts", () => { // And I have read them await util.goTo(room2); - await util.assertUnreadThread("Root"); - await util.openThread("Root"); + await util.assertUnreadThread("Root1"); + await util.openThread("Root1"); await util.assertUnreadLessThan(room2, 4); await util.openThread("Root2"); await util.assertRead(room2); @@ -366,10 +363,9 @@ test.describe("Read receipts", () => { // Then the room and thread are still read await util.assertStillRead(room2); await util.goTo(room2); - await util.assertReadThread("Root"); + await util.assertReadThread("Root1"); }); - // XXX: fails because it flakes (on CI only) - test.skip("Reading an unread thread after a redaction of the latest message makes it read", async ({ + test("Reading an unread thread after a redaction of the latest message makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -399,8 +395,7 @@ test.describe("Read receipts", () => { await util.goTo(room2); await util.assertReadThread("Root"); }); - // XXX: fails because the unread count is still 1 when it should be 0 - test.skip("Reading an unread thread after a redaction of the latest message makes it read after restart", async ({ + test("Reading an unread thread after a redaction of the latest message makes it read after restart", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -432,8 +427,7 @@ test.describe("Read receipts", () => { // Then the room is still read await util.assertRead(room2); }); - // XXX: fails because it flakes (on CI only) - test.skip("Reading an unread thread after a redaction of an older message makes it read", async ({ + test("Reading an unread thread after a redaction of an older message makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -463,8 +457,7 @@ test.describe("Read receipts", () => { await util.goTo(room2); await util.assertReadThread("Root"); }); - // XXX: fails because it flakes (on CI only) - test.skip("Marking an unread thread as read after a redaction makes it read", async ({ + test("Marking an unread thread as read after a redaction makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -490,8 +483,7 @@ test.describe("Read receipts", () => { await util.goTo(room2); await util.assertReadThread("Root"); }); - // XXX: fails because the room has an unread dot after I marked it as read - test.skip("Sending and redacting a message after marking the thread as read leaves it read", async ({ + test("Sending and redacting a message after marking the thread as read leaves it read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -518,8 +510,7 @@ test.describe("Read receipts", () => { await util.goTo(room2); await util.assertReadThread("Root"); }); - // XXX: fails because the room has an unread dot after I marked it as read - test.skip("Redacting a message after marking the thread as read leaves it read", async ({ + test("Redacting a message after marking the thread as read leaves it read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -544,8 +535,7 @@ test.describe("Read receipts", () => { await util.goTo(room2); await util.assertReadThread("Root"); }); - // XXX: fails because it flakes - sometimes the room is still unread after opening the thread (initially) - test.skip("Reacting to a redacted message leaves the thread read", async ({ + test("Reacting to a redacted message leaves the thread read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -573,8 +563,7 @@ test.describe("Read receipts", () => { // Then the room is unread await util.assertStillRead(room2); }); - // XXX: fails because the room is still unread after opening the thread (initially) - test.skip("Editing a redacted message leaves the thread read", async ({ + test("Editing a redacted message leaves the thread read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -602,8 +591,7 @@ test.describe("Read receipts", () => { // Then the room is unread await util.assertStillRead(room2); }); - // XXX: failed because flakes: https://github.com/vector-im/element-web/issues/26594 - test.skip("Reading a thread after a reaction to a redacted message marks the thread as read", async ({ + test("Reading a thread after a reaction to a redacted message marks the thread as read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -629,8 +617,7 @@ test.describe("Read receipts", () => { await util.assertRead(room2); await util.assertReadThread("Root"); }); - // XXX: fails because the unread count stays at 1 instead of zero - test.skip("Reading a thread containing a redacted, edited message marks the thread as read", async ({ + test("Reading a thread containing a redacted, edited message marks the thread as read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -655,8 +642,7 @@ test.describe("Read receipts", () => { await util.assertRead(room2); await util.assertReadThread("Root"); }); - // XXX: fails because the read count drops to 1 but not to zero (this is a genuine stuck unread case) - test.skip("Reading a reply to a redacted message marks the thread as read", async ({ + test("Reading a reply to a redacted message marks the thread as read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -681,8 +667,7 @@ test.describe("Read receipts", () => { await util.assertRead(room2); await util.assertReadThread("Root"); }); - // XXX: fails because flakes saying 2 unread instead of 1 - test.skip("Reading a thread root when its only message has been redacted leaves the room read", async ({ + test("Reading a thread root when its only message has been redacted leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -703,8 +688,7 @@ test.describe("Read receipts", () => { // Then the room is read await util.assertRead(room2); }); - // XXX: fails because flakes with matrix-js-sdk#3798 (only when all other tests are enabled!) - test.skip("A thread with a redacted unread is still read after restart", async ({ + test("A thread with a redacted unread is still read after restart", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -738,8 +722,7 @@ test.describe("Read receipts", () => { await util.goTo(room2); await util.assertReadThread("Root"); }); - // XXX: fails because it flakes - test.skip("A thread with a read redaction is still read after restart", async ({ + test("A thread with a read redaction is still read after restart", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -748,16 +731,16 @@ test.describe("Read receipts", () => { // Given my receipt points at a redacted thread message await util.goTo(room1); await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "ThreadMsg1"), - msg.threadedOff("Root", "ThreadMsg2"), + "Root1", + msg.threadedOff("Root1", "ThreadMsg1"), + msg.threadedOff("Root1", "ThreadMsg2"), "Root2", msg.threadedOff("Root2", "Root2->A"), ]); await util.assertUnread(room2, 5); await util.goTo(room2); - await util.assertUnreadThread("Root"); - await util.openThread("Root"); + await util.assertUnreadThread("Root1"); + await util.openThread("Root1"); await util.assertUnreadLessThan(room2, 4); await util.openThread("Root2"); await util.assertRead(room2); @@ -767,7 +750,7 @@ test.describe("Read receipts", () => { await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg2")]); await util.assertStillRead(room2); await util.goTo(room2); - await util.assertReadThread("Root"); + await util.assertReadThread("Root1"); // When I restart await util.saveAndReload(); @@ -775,8 +758,7 @@ test.describe("Read receipts", () => { // Then the room is still read await util.assertRead(room2); }); - // XXX: fails for the same reason as "Reading a reply to a redacted message marks the thread as read" - test.skip("A thread with an unread reply to a redacted message is still unread after restart", async ({ + test("A thread with an unread reply to a redacted message is still unread after restart", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -806,8 +788,7 @@ test.describe("Read receipts", () => { await util.assertRead(room2); await util.assertReadThread("Root"); }); - // XXX: fails for the same reason as "Reading a reply to a redacted message marks the thread as read - test.skip("A thread with a read reply to a redacted message is still read after restart", async ({ + test("A thread with a read reply to a redacted message is still read after restart", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -867,8 +848,7 @@ test.describe("Read receipts", () => { // Then the room is still read await util.assertStillRead(room2); }); - // TODO: Can't open a thread on a redacted thread root - test.skip("Redacting a thread root still allows us to read the thread", async ({ + test("Redacting a thread root still allows us to read the thread", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -892,12 +872,13 @@ test.describe("Read receipts", () => { // And I can open the thread and read it await util.goTo(room2); await util.assertUnread(room2, 2); - await util.openThread("Root"); + // The redacted message gets collapsed into, "foo was invited, joined and removed a message" + await util.openCollapsedMessage(1); + await util.openThread("Message deleted"); await util.assertRead(room2); await util.assertReadThread("Root"); }); - // TODO: Can't open a thread on a redacted thread root - test.skip("Sending a threaded message onto a redacted thread root leaves the room unread", async ({ + test("Sending a threaded message onto a redacted thread root leaves the room unread", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -923,7 +904,7 @@ test.describe("Read receipts", () => { // Then the room and thread are unread await util.assertUnread(room2, 1); await util.goTo(room2); - await util.assertUnreadThread("Root"); + await util.assertUnreadThread("Message deleted"); }); test("Reacting to a redacted thread root leaves the room read", async ({ roomAlpha: room1, diff --git a/playwright/e2e/right-panel/file-panel.spec.ts b/playwright/e2e/right-panel/file-panel.spec.ts index 9d1d4abc0931..84e7614e8efc 100644 --- a/playwright/e2e/right-panel/file-panel.spec.ts +++ b/playwright/e2e/right-panel/file-panel.spec.ts @@ -58,9 +58,9 @@ test.describe("FilePanel", () => { test("should list tiles on the panel", async ({ page }) => { // Upload multiple files - await uploadFile(page, "cypress/fixtures/riot.png"); // Image - await uploadFile(page, "cypress/fixtures/1sec.ogg"); // Audio - await uploadFile(page, "cypress/fixtures/matrix-org-client-versions.json"); // JSON + await uploadFile(page, "playwright/sample-files/riot.png"); // Image + await uploadFile(page, "playwright/sample-files/1sec.ogg"); // Audio + await uploadFile(page, "playwright/sample-files/matrix-org-client-versions.json"); // JSON const roomViewBody = page.locator(".mx_RoomView_body"); // Assert that all of the file were uploaded and rendered @@ -143,7 +143,7 @@ test.describe("FilePanel", () => { test("should render the audio player and play the audio file on the panel", async ({ page }) => { // Upload an image file - await uploadFile(page, "cypress/fixtures/1sec.ogg"); + await uploadFile(page, "playwright/sample-files/1sec.ogg"); const audioBody = page.locator( ".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container", @@ -178,7 +178,7 @@ test.describe("FilePanel", () => { const size = "1.12 KB"; // actual file size in kibibytes (1024 bytes) // Upload a file - await uploadFile(page, "cypress/fixtures/matrix-org-client-versions.json"); + await uploadFile(page, "playwright/sample-files/matrix-org-client-versions.json"); const tile = page.locator(".mx_FilePanel .mx_EventTile"); // Assert that the file size is displayed in kibibytes, not kilobytes (1000 bytes) @@ -192,7 +192,7 @@ test.describe("FilePanel", () => { test.describe("download", () => { test("should download an image via the link on the panel", async ({ page, context }) => { // Upload an image file - await uploadFile(page, "cypress/fixtures/riot.png"); + await uploadFile(page, "playwright/sample-files/riot.png"); // Detect the image file on the panel const imageBody = page.locator( diff --git a/playwright/e2e/right-panel/notification-panel.spec.ts b/playwright/e2e/right-panel/notification-panel.spec.ts index 9e3f7e03de8c..6223c1c13f7d 100644 --- a/playwright/e2e/right-panel/notification-panel.spec.ts +++ b/playwright/e2e/right-panel/notification-panel.spec.ts @@ -22,6 +22,7 @@ const NAME = "Alice"; test.describe("NotificationPanel", () => { test.use({ displayName: NAME, + labsFlags: ["feature_notifications"], }); test.beforeEach(async ({ app, user }) => { @@ -29,7 +30,6 @@ test.describe("NotificationPanel", () => { }); test("should render empty state", async ({ page, app }) => { - await app.labs.enableLabsFeature("feature_notifications"); await app.viewRoomByName(ROOM_NAME); await page.getByRole("button", { name: "Notifications" }).click(); diff --git a/playwright/e2e/room-directory/room-directory.spec.ts b/playwright/e2e/room-directory/room-directory.spec.ts index 6d086508955b..5068f8e5cc03 100644 --- a/playwright/e2e/room-directory/room-directory.spec.ts +++ b/playwright/e2e/room-directory/room-directory.spec.ts @@ -41,9 +41,9 @@ test.describe("Room Directory", () => { // 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.getByLabel( - "Publish this room to the public in localhost's room directory?", - ); + 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(); diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 45bb6a68109a..2d0af8a6df98 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -25,10 +25,9 @@ test.describe("Room Header", () => { }); test.describe("with feature_notifications enabled", () => { - test.beforeEach(async ({ app }) => { - await app.labs.enableLabsFeature("feature_notifications"); + test.use({ + labsFlags: ["feature_notifications"], }); - test("should render default buttons properly", async ({ page, app, user }) => { await app.client.createRoom({ name: "Test Room" }); await app.viewRoomByName("Test Room"); @@ -101,9 +100,7 @@ test.describe("Room Header", () => { }); test.describe("with feature_pinning enabled", () => { - test.beforeEach(async ({ app }) => { - await app.labs.enableLabsFeature("feature_pinning"); - }); + test.use({ labsFlags: ["feature_pinning"] }); test("should render the pin button for pinned messages card", async ({ page, app, user }) => { await app.client.createRoom({ name: "Test Room" }); @@ -126,9 +123,7 @@ test.describe("Room Header", () => { }); test.describe("with a video room", () => { - test.beforeEach(async ({ app }) => { - await app.labs.enableLabsFeature("feature_video_rooms"); - }); + test.use({ labsFlags: ["feature_video_rooms"] }); const createVideoRoom = async (page: Page, app: ElementAppPage) => { await page.locator(".mx_LeftPanel_roomListContainer").getByRole("button", { name: "Add room" }).click(); @@ -142,33 +137,36 @@ test.describe("Room Header", () => { await app.viewRoomByName("Test video room"); }; - test("should render buttons for room options, beta pill, invite, chat, and room info", async ({ - page, - app, - user, - }) => { - await app.labs.enableLabsFeature("feature_notifications"); - await createVideoRoom(page, app); - - const header = page.locator(".mx_LegacyRoomHeader"); - // Names (aria-label) of the buttons on the video room header - const expectedButtonNames = [ - "Room options", - "Video rooms are a beta feature Click for more info", // Beta pill - "Invite", - "Chat", - "Room info", - ]; - - // Assert they are found and visible - for (const name of expectedButtonNames) { - await expect(header.getByRole("button", { name })).toBeVisible(); - } - - // Assert that there is not a button except those buttons - await expect(header.getByRole("button")).toHaveCount(7); - - await expect(header).toMatchScreenshot("room-header-video-room.png"); + test.describe("and with feature_notifications enabled", () => { + test.use({ labsFlags: ["feature_video_rooms", "feature_notifications"] }); + + test("should render buttons for room options, beta pill, invite, chat, and room info", async ({ + page, + app, + user, + }) => { + await createVideoRoom(page, app); + + const header = page.locator(".mx_LegacyRoomHeader"); + // Names (aria-label) of the buttons on the video room header + const expectedButtonNames = [ + "Room options", + "Video rooms are a beta feature Click for more info", // Beta pill + "Invite", + "Chat", + "Room info", + ]; + + // Assert they are found and visible + for (const name of expectedButtonNames) { + await expect(header.getByRole("button", { name })).toBeVisible(); + } + + // Assert that there is not a button except those buttons + await expect(header.getByRole("button")).toHaveCount(7); + + await expect(header).toMatchScreenshot("room-header-video-room.png"); + }); }); test("should render a working chat button which opens the timeline on a right panel", async ({ @@ -180,8 +178,8 @@ test.describe("Room Header", () => { await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click(); - // Assert that the video is rendered - await expect(page.locator(".mx_CallView video")).toBeVisible(); + // Assert that the call view is still visible + await expect(page.locator(".mx_CallView")).toBeVisible(); // Assert that GELS is visible await expect( diff --git a/playwright/e2e/room/room.spec.ts b/playwright/e2e/room/room.spec.ts index 2bd9e06c0702..43edeaab386f 100644 --- a/playwright/e2e/room/room.spec.ts +++ b/playwright/e2e/room/room.spec.ts @@ -60,4 +60,43 @@ test.describe("Room Directory", () => { // confirm the room was loaded await expect(page.getByText("Charlie joined the room")).toBeVisible(); }); + + test("should memorize the timeline position when switch Room A -> Room B -> Room A", async ({ + page, + app, + user, + }) => { + // Create the two rooms + const roomAId = await app.client.createRoom({ name: "Room A" }); + const roomBId = await app.client.createRoom({ name: "Room B" }); + // Display Room A + await app.viewRoomById(roomAId); + + // Send the first message and get the event ID + const { event_id: eventId } = await app.client.sendMessage(roomAId, { body: "test0", msgtype: "m.text" }); + // Send 49 more messages + for (let i = 1; i < 50; i++) { + await app.client.sendMessage(roomAId, { body: `test${i}`, msgtype: "m.text" }); + } + + // Wait for all the messages to be displayed + await expect( + page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText("test49"), + ).toBeVisible(); + + // Display the first message + await page.goto(`/#/room/${roomAId}/${eventId}`); + + // Wait for the first message to be displayed + await expect(page.locator(".mx_MTextBody .mx_EventTile_body").getByText("test0")).toBeInViewport(); + + // Display Room B + await app.viewRoomById(roomBId); + // Display Room A + await app.viewRoomById(roomAId); + + // The timeline should display the first message + // The previous position before switching to Room B should be remembered + await expect(page.locator(".mx_MTextBody .mx_EventTile_body").getByText("test0")).toBeInViewport(); + }); }); diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts new file mode 100644 index 000000000000..e1efa7ec6f99 --- /dev/null +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -0,0 +1,375 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Page, Request } from "@playwright/test"; + +import { test, expect } from "../../element-web-test"; +import type { ElementAppPage } from "../../pages/ElementAppPage"; +import type { Bot } from "../../pages/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); + }; + + const bumpRoom = async (roomId: string, app: ElementAppPage) => { + // Send a message into the given room, this should bump the room to the top + console.log("sendEvent", app.client.sendEvent); + await app.client.sendEvent(roomId, null, "m.room.message", { + body: "Hello world", + msgtype: "m.text", + }); + }; + + const createAndJoinBot = async (app: ElementAppPage, bot: Bot): Promise => { + 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; + }; + + test.skip("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", async ({ + page, + app, + }) => { + // create rooms and check room names are correct + for (const fruit of ["Apple", "Pineapple", "Orange"]) { + await app.client.createRoom({ name: fruit }); + await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + } + + // Check count, 3 fruits + 1 room created in beforeEach = 4 + await expect(page.locator(".mx_RoomSublist_tiles").getByRole("treeitem")).toHaveCount(4); + await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page); + + const locator = page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomSublist_headerContainer"); + await locator.hover(); + await locator.getByRole("button", { name: "List options" }).click(); + + // force click as the radio button's size is zero + await page.getByRole("menuitemradio", { name: "A-Z" }).dispatchEvent("click"); + await expect(page.locator(".mx_StyledRadioButton_checked").getByText("A-Z")).toBeVisible(); + + await page.pause(); + await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page); + }); + + test.skip("should move rooms around as new events arrive", async ({ page, app }) => { + // create rooms and check room names are correct + const roomIds: string[] = []; + for (const fruit of ["Apple", "Pineapple", "Orange"]) { + const id = await app.client.createRoom({ name: fruit }); + roomIds.push(id); + await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + } + + // Select the Test Room + await page.getByRole("treeitem", { name: "Test Room" }).click(); + const [apple, pineapple, orange] = roomIds; + await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page); + await bumpRoom(apple, app); + await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page); + await bumpRoom(orange, app); + await checkOrder(["Orange", "Apple", "Pineapple", "Test Room"], page); + await bumpRoom(orange, app); + await checkOrder(["Orange", "Apple", "Pineapple", "Test Room"], page); + await bumpRoom(pineapple, app); + await checkOrder(["Pineapple", "Orange", "Apple", "Test Room"], page); + }); + + test.skip("should not move the selected room: it should be sticky", async ({ page, app }) => { + // create rooms and check room names are correct + const roomIds: string[] = []; + for (const fruit of ["Apple", "Pineapple", "Orange"]) { + const id = await app.client.createRoom({ name: fruit }); + roomIds.push(id); + await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + } + + // Given a list of Orange, Pineapple, Apple - if Pineapple is active and a message is sent in Apple, the list should + // turn into Apple, Pineapple, Orange - the index position of Pineapple never changes even though the list should technically + // be Apple, Orange Pineapple - only when you click on a different room do things reshuffle. + + // Select the Pineapple room + await page.getByRole("treeitem", { name: "Pineapple" }).click(); + await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page); + + // Move Apple + await bumpRoom(roomIds[0], app); + await checkOrder(["Apple", "Pineapple", "Orange", "Test Room"], page); + + // Select the Test Room + await page.getByRole("treeitem", { name: "Test Room" }).click(); + + // the rooms reshuffle to match reality + 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); + + // send a message in the test room: unread notification count should increment + await bob.sendMessage(roomId, "Hello World"); + + const treeItemLocator1 = page.getByRole("treeitem", { name: "Test Room 1 unread message." }); + await expect(treeItemLocator1.locator(".mx_NotificationBadge_count")).toHaveText("1"); + // await expect(page.locator(".mx_NotificationBadge")).not.toHaveClass("mx_NotificationBadge_highlighted"); + await expect(treeItemLocator1.locator(".mx_NotificationBadge")).not.toHaveClass( + /mx_NotificationBadge_highlighted/, + ); + + // send an @mention: highlight count (red) should be 2. + await bob.sendMessage(roomId, `Hello ${user.displayName}`); + const treeItemLocator2 = page.getByRole("treeitem", { + name: "Test Room 2 unread messages including mentions.", + }); + await expect(treeItemLocator2.locator(".mx_NotificationBadge_count")).toHaveText("2"); + await expect(treeItemLocator2.locator(".mx_NotificationBadge")).toHaveClass(/mx_NotificationBadge_highlighted/); + + // click on the room, the notif counts should disappear + await page.getByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).click(); + await expect( + page.getByRole("treeitem", { name: "Test Room" }).locator("mx_NotificationBadge_count"), + ).not.toBeAttached(); + }); + + test.skip("should not show unread indicators", async ({ page, app, bot }) => { + // 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" }); + await locator.hover(); + await locator.getByRole("button", { name: "Notification options" }).click(); + await page.getByRole("menuitemradio", { name: "Mute room" }).click(); + + // create a new room so we know when the message has been received as it'll re-shuffle the room list + await app.client.createRoom({ name: "Dummy" }); + + await checkOrder(["Dummy", "Test Room"], page); + + await bot.sendMessage(roomId, "Do you read me?"); + + // wait for this message to arrive, tell by the room list resorting + await checkOrder(["Test Room", "Dummy"], page); + + await expect( + page.getByRole("treeitem", { name: "Test Room" }).locator(".mx_NotificationBadge"), + ).not.toBeAttached(); + }); + + 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 locator.locator(".mx_ToggleSwitch_ball").click(); + 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); + + const clientUserId = await app.client.evaluate((client) => client.getUserId()); + + // invite bot into 3 rooms: + // - roomJoin: will join this room + // - roomReject: will reject the invite + // - roomRescind: will make Bob rescind the invite + const roomNames = ["Room to Join", "Room to Reject", "Room to Rescind"]; + const roomRescind = await bot.evaluate( + async (client, { roomNames, clientUserId }) => { + const rooms = await Promise.all(roomNames.map((name) => client.createRoom({ name }))); + await Promise.all(rooms.map((room) => client.invite(room.room_id, clientUserId))); + return rooms[2].room_id; + }, + { roomNames, clientUserId }, + ); + + await expect( + page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(3); + + // Select the room to join + await page.getByRole("treeitem", { name: "Room to Join" }).click(); + + // Accept the invite + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + await checkOrder(["Room to Join", "Test Room"], page); + + // Select the room to reject + await page.getByRole("treeitem", { name: "Room to Reject" }).click(); + + // Reject the invite + await page.locator(".mx_RoomView").getByRole("button", { name: "Reject", exact: true }).click(); + + await expect( + page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(2); + + // check the lists are correct + await checkOrder(["Room to Join", "Test Room"], page); + + const titleLocator = page.getByRole("group", { name: "Invites" }).locator(".mx_RoomTile_title"); + await expect(titleLocator).toHaveCount(1); + await expect(titleLocator).toHaveText("Room to Rescind"); + + // now rescind the invite + await bot.evaluate( + async (client, { roomRescind, clientUserId }) => { + client.kick(roomRescind, clientUserId); + }, + { roomRescind, clientUserId }, + ); + + // Wait for the rescind to take effect and check the joined list once more + await expect( + page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(2); + + await checkOrder(["Room to Join", "Test Room"], page); + }); + + test("should show a favourite DM only in the favourite sublist", async ({ page, app }) => { + const roomId = await app.client.createRoom({ + name: "Favourite DM", + is_direct: true, + }); + await app.client.evaluate(async (client, roomId) => { + client.setRoomTag(roomId, "m.favourite", { order: 0.5 }); + }, roomId); + await expect(page.getByRole("group", { name: "Favourites" }).getByText("Favourite DM")).toBeVisible(); + await expect(page.getByRole("group", { name: "People" }).getByText("Favourite DM")).not.toBeAttached(); + }); + + // 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 }) => { + await app.client.createRoom({ name: "Other Room" }); + await expect(page.getByRole("treeitem", { name: "Other Room" })).toBeVisible(); + await app.client.sendMessage(roomId, "Hello world"); + + // select the room + await page.getByRole("treeitem", { name: "Test Room" }).click(); + + await expect(page.locator(".mx_ReplyPreview")).not.toBeAttached(); + + // click reply-to on the Hello World message + const locator = page.locator(".mx_EventTile_last"); + await locator.getByText("Hello world").hover(); + await locator.getByRole("button", { name: "Reply", exact: true }).click({}); + + // check it's visible + await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); + + // now click Other Room + await page.getByRole("treeitem", { name: "Other Room" }).click(); + + // ensure the reply-to disappears + await expect(page.locator(".mx_ReplyPreview")).not.toBeAttached(); + + // click back + await page.getByRole("treeitem", { name: "Test Room" }).click(); + + // ensure the reply-to reappears + await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); + }); + + // 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 }) => { + // 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"); + + // select the room + await page.getByRole("treeitem", { name: "Test Room" }).click(); + await expect(page.locator(".mx_ReplyPreview")).not.toBeAttached(); + + // click reply-to on the Reply to me message + const locator = page.locator(".mx_EventTile").last(); + await locator.getByText("Reply to me").hover(); + await locator.getByRole("button", { name: "Reply", exact: true }).click(); + + // check it's visible + await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); + + // now click on the permalink for Permalink me + await page.locator(".mx_EventTile").filter({ hasText: "Permalink me" }).locator("a").dispatchEvent("click"); + + // make sure it is now selected with the little green | + await expect(page.locator(".mx_EventTile_selected").filter({ hasText: "Permalink me" })).toBeVisible(); + + // ensure the reply-to does not disappear + await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); + }); + + test.skip("should send unsubscribe_rooms for every room switch", async ({ page, app }) => { + // create rooms and check room names are correct + const roomIds: string[] = []; + for (const fruit of ["Apple", "Pineapple", "Orange"]) { + const id = await app.client.createRoom({ name: fruit }); + roomIds.push(id); + await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + } + const [roomAId, roomPId] = roomIds; + + const assertUnsubExists = (request: Request, subRoomId: string, unsubRoomId: string) => { + const body = request.postDataJSON(); + // There may be a request without a txn_id, ignore it, as there won't be any subscription changes + if (body.txn_id === undefined) { + return; + } + expect(body.unsubscribe_rooms).toEqual([unsubRoomId]); + expect(body.room_subscriptions).not.toHaveProperty(unsubRoomId); + expect(body.room_subscriptions).toHaveProperty(subRoomId); + }; + + let promise = page.waitForRequest(/sync/); + + // Select the Test Room + await page.getByRole("treeitem", { name: "Apple", exact: true }).click(); + + // and wait for playwright to get the request + const roomSubscriptions = (await promise).postDataJSON().room_subscriptions; + expect(roomSubscriptions, "room_subscriptions is object").toBeDefined(); + + // Switch to another room + promise = page.waitForRequest(/sync/); + await page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(); + assertUnsubExists(await promise, roomPId, roomAId); + + // And switch to even another room + promise = page.waitForRequest(/sync/); + await page.getByRole("treeitem", { name: "Apple", exact: true }).click(); + assertUnsubExists(await promise, roomPId, roomAId); + + // TODO: Add tests for encrypted rooms + }); +}); diff --git a/playwright/e2e/spaces/spaces.spec.ts b/playwright/e2e/spaces/spaces.spec.ts index 396ca803a5e4..0e7aa0e4f4da 100644 --- a/playwright/e2e/spaces/spaces.spec.ts +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -71,7 +71,7 @@ test.describe("Spaces", () => { await contextMenu .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') - .setInputFiles("cypress/fixtures/riot.png"); + .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!"); @@ -102,7 +102,7 @@ test.describe("Spaces", () => { await menu .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') - .setInputFiles("cypress/fixtures/riot.png"); + .setInputFiles("playwright/sample-files/riot.png"); await menu.getByRole("textbox", { name: "Name" }).fill("This is not a Riot"); await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible(); await menu.getByRole("textbox", { name: "Description" }).fill("This is a private space of mourning Riot.im..."); @@ -147,7 +147,7 @@ test.describe("Spaces", () => { await menu .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') - .setInputFiles("cypress/fixtures/riot.png"); + .setInputFiles("playwright/sample-files/riot.png"); await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible(); await menu.getByRole("textbox", { name: "Description" }).fill("This is a personal space to mourn Riot.im..."); await menu.getByRole("textbox", { name: "Name" }).fill("This is my Riot"); diff --git a/playwright/e2e/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts new file mode 100644 index 000000000000..177eccdc106e --- /dev/null +++ b/playwright/e2e/spotlight/spotlight.spec.ts @@ -0,0 +1,393 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; +import { Filter } from "../../pages/Spotlight"; +import { Bot } from "../../pages/bot"; +import type { Locator, Page } from "@playwright/test"; +import type { ElementAppPage } from "../../pages/ElementAppPage"; + +function roomHeaderName(page: Page): Locator { + return page.locator(".mx_LegacyRoomHeader_nametext"); +} + +async function startDM(app: ElementAppPage, page: Page, name: string): Promise { + const spotlight = await app.openSpotlight(); + await spotlight.filter(Filter.People); + await spotlight.search(name); + await page.waitForTimeout(1000); // wait for the dialog code to settle + await expect(spotlight.dialog.locator(".mx_Spinner")).not.toBeAttached(); + const result = spotlight.results; + await expect(result).toHaveCount(1); + await expect(result.first()).toContainText(name); + await result.first().click(); + + // send first message to start DM + const locator = page.getByRole("textbox", { name: "Send a message…" }); + await expect(locator).toBeFocused(); + await locator.fill("Hey!"); + await locator.press("Enter"); + // The DM room is created at this point, this can take a little bit of time + await expect(page.locator(".mx_EventTile_body").getByText("Hey!")).toBeAttached({ timeout: 3000 }); + await expect(page.getByRole("group", { name: "People" }).getByText(name)).toBeAttached(); +} + +test.describe("Spotlight", () => { + const bot1Name = "BotBob"; + let bot1: Bot; + + const bot2Name = "ByteBot"; + let bot2: Bot; + + const room1Name = "247"; + let room1Id: string; + + const room2Name = "Lounge"; + let room2Id: string; + + const room3Name = "Public"; + let room3Id: string; + + test.use({ + displayName: "Jim", + }); + + test.beforeEach(async ({ page, homeserver, app, user }) => { + bot1 = new Bot(page, homeserver, { displayName: bot1Name, autoAcceptInvites: true }); + bot2 = new Bot(page, homeserver, { displayName: bot2Name, autoAcceptInvites: true }); + const Visibility = await page.evaluate(() => (window as any).matrixcs.Visibility); + + room1Id = await app.client.createRoom({ name: room1Name, visibility: Visibility.Public }); + + await bot1.joinRoom(room1Id); + const bot1UserId = await bot1.evaluate((client) => client.getUserId()); + room2Id = await bot2.createRoom({ name: room2Name, visibility: Visibility.Public }); + await bot2.inviteUser(room2Id, bot1UserId); + + room3Id = await bot2.createRoom({ + name: room3Name, + visibility: Visibility.Public, + initial_state: [ + { + type: "m.room.history_visibility", + state_key: "", + content: { + history_visibility: "world_readable", + }, + }, + ], + }); + await bot2.inviteUser(room3Id, bot1UserId); + + await page.goto("/#/room/" + room1Id); + await expect(page.locator(".mx_RoomSublist_skeletonUI")).not.toBeAttached(); + }); + + test("should be able to add and remove filters via keyboard", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(1000); // wait for the dialog to settle, otherwise our keypresses might race with an update + + // initially, public spaces should be highlighted (because there are no other suggestions) + await expect(spotlight.dialog.locator("#mx_SpotlightDialog_button_explorePublicSpaces")).toHaveAttribute( + "aria-selected", + "true", + ); + + // hitting enter should enable the public rooms filter + await spotlight.searchBox.press("Enter"); + await expect(spotlight.dialog.locator(".mx_SpotlightDialog_filter")).toHaveText("Public spaces"); + await spotlight.searchBox.press("Backspace"); + await expect(spotlight.dialog.locator(".mx_SpotlightDialog_filter")).not.toBeAttached(); + await page.waitForTimeout(200); // Again, wait to settle so keypresses arrive correctly + + await spotlight.searchBox.press("ArrowDown"); + await expect(spotlight.dialog.locator("#mx_SpotlightDialog_button_explorePublicRooms")).toHaveAttribute( + "aria-selected", + "true", + ); + await spotlight.searchBox.press("Enter"); + await expect(spotlight.dialog.locator(".mx_SpotlightDialog_filter")).toHaveText("Public rooms"); + await spotlight.searchBox.press("Backspace"); + await expect(spotlight.dialog.locator(".mx_SpotlightDialog_filter")).not.toBeAttached(); + }); + + test("should find joined rooms", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.search(room1Name); + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(room1Name); + await resultLocator.first().click(); + expect(page.url()).toContain(room1Id); + await expect(roomHeaderName(page)).toContainText(room1Name); + }); + + test("should find known public rooms", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.PublicRooms); + await spotlight.search(room1Name); + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(room1Name); + await expect(resultLocator.first()).toContainText("View"); + await resultLocator.first().click(); + expect(page.url()).toContain(room1Id); + await expect(roomHeaderName(page)).toContainText(room1Name); + }); + + test("should find unknown public rooms", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.PublicRooms); + await spotlight.search(room2Name); + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(room2Name); + await expect(resultLocator.first()).toContainText("Join"); + await resultLocator.first().click(); + expect(page.url()).toContain(room2Id); + await expect(page.locator(".mx_RoomView_MessageList")).toHaveCount(1); + await expect(roomHeaderName(page)).toContainText(room2Name); + }); + + test("should find unknown public world readable rooms", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.PublicRooms); + await spotlight.search(room3Name); + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(room3Name); + await expect(resultLocator.first()).toContainText("View"); + await resultLocator.first().click(); + expect(page.url()).toContain(room3Id); + await page.getByRole("button", { name: "Join the discussion" }).click(); + await expect(roomHeaderName(page)).toHaveText(room3Name); + }); + + // TODO: We currently can’t test finding rooms on other homeservers/other protocols + // We obviously don’t have federation or bridges in local e2e tests + test.skip("should find unknown public rooms on other homeservers", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.PublicRooms); + await spotlight.search(room3Name); + await page.locator("[aria-haspopup=true][role=button]").click(); + + await page + .locator(".mx_GenericDropdownMenu_Option--header") + .filter({ hasText: "matrix.org" }) + .locator("..") + .locator("[role=menuitemradio]") + .click(); + await page.waitForTimeout(3_600_000); + + await page.waitForTimeout(500); // wait for the dialog to settle + + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(room3Name); + await expect(resultLocator.first()).toContainText(room3Id); + }); + + test("should find known people", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.People); + await spotlight.search(bot1Name); + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(bot1Name); + await resultLocator.first().click(); + await expect(roomHeaderName(page)).toHaveText(bot1Name); + }); + + /** + * Search sends the correct query to Synapse. + * Synapse doesn't return the user in the result list. + * Waiting for the profile to be available via APIs before the tests didn't help. + * + * https://github.com/matrix-org/synapse/issues/16472 + */ + test.skip("should find unknown people", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.People); + await spotlight.search(bot2Name); + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(bot2Name); + await resultLocator.first().click(); + await expect(roomHeaderName(page)).toHaveText(bot2Name); + }); + + test("should find group DMs by usernames or user ids", async ({ page, app }) => { + // First we want to share a room with both bots to ensure we’ve got their usernames cached + const bot2UserId = await bot2.evaluate((client) => client.getUserId()); + await app.client.inviteUser(room1Id, bot2UserId); + + // Starting a DM with ByteBot (will be turned into a group dm later) + let spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.People); + await spotlight.search(bot2Name); + let resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(bot2Name); + await resultLocator.first().click(); + + // Send first message to actually start DM + await expect(roomHeaderName(page)).toHaveText(bot2Name); + const locator = page.getByRole("textbox", { name: "Send a message…" }); + await locator.fill("Hey!"); + await locator.press("Enter"); + + // Assert DM exists by checking for the first message and the room being in the room list + await expect(page.locator(".mx_EventTile_body").filter({ hasText: "Hey!" })).toBeAttached({ timeout: 3000 }); + await expect(page.getByRole("group", { name: "People" })).toContainText(bot2Name); + + // Invite BotBob into existing DM with ByteBot + const dmRooms = await app.client.evaluate((client, userId) => { + const map = client.getAccountData("m.direct")?.getContent>(); + return map[userId] ?? []; + }, bot2UserId); + expect(dmRooms).toHaveLength(1); + const groupDmName = await app.client.evaluate((client, id) => client.getRoom(id).name, dmRooms[0]); + const bot1UserId = await bot1.evaluate((client) => client.getUserId()); + await app.client.inviteUser(dmRooms[0], bot1UserId); + await expect(roomHeaderName(page).first()).toContainText(groupDmName); + await expect(page.getByRole("group", { name: "People" }).first()).toContainText(groupDmName); + + // Search for BotBob by id, should return group DM and user + spotlight = await app.openSpotlight(); + await spotlight.filter(Filter.People); + await spotlight.search(bot1UserId); + await page.waitForTimeout(1000); // wait for the dialog to settle + resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(2); + await expect( + spotlight.dialog + .locator(".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option") + .filter({ hasText: groupDmName }), + ).toBeAttached(); + + // Search for ByteBot by id, should return group DM and user + spotlight = await app.openSpotlight(); + await spotlight.filter(Filter.People); + await spotlight.search(bot2UserId); + await page.waitForTimeout(1000); // wait for the dialog to settle + resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(2); + await expect( + spotlight.dialog + .locator(".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option") + .filter({ hasText: groupDmName }) + .last(), + ).toBeAttached(); + }); + + // Test against https://github.com/vector-im/element-web/issues/22851 + test("should show each person result only once", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.People); + const bot1UserId = await bot1.evaluate((client) => client.getUserId()); + + // 2 rounds of search to simulate the bug conditions. Specifically, the first search + // should have 1 result (not 2) and the second search should also have 1 result (instead + // of the super buggy 3 described by https://github.com/vector-im/element-web/issues/22851) + // + // We search for user ID to trigger the profile lookup within the dialog. + for (let i = 0; i < 2; i++) { + console.log("Iteration: " + i); + await spotlight.search(bot1UserId); + await page.waitForTimeout(1000); // wait for the dialog to settle + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(bot1UserId); + } + }); + + test("should allow opening group chat dialog", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.People); + await spotlight.search(bot2Name); + await page.waitForTimeout(3000); // wait for the dialog to settle + + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + await expect(resultLocator.first()).toContainText(bot2Name); + + await expect(spotlight.dialog.locator(".mx_SpotlightDialog_startGroupChat")).toContainText( + "Start a group chat", + ); + await spotlight.dialog.locator(".mx_SpotlightDialog_startGroupChat").click(); + await expect(page.getByRole("dialog")).toContainText("Direct Messages"); + }); + + test("should close spotlight after starting a DM", async ({ page, app }) => { + await startDM(app, page, bot1Name); + await expect(page.locator(".mx_SpotlightDialog")).toHaveCount(0); + }); + + test("should show the same user only once", async ({ page, app }) => { + await startDM(app, page, bot1Name); + await page.goto("/#/home"); + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.People); + await spotlight.search(bot1Name); + await page.waitForTimeout(3000); // wait for the dialog to settle + await expect(spotlight.dialog.locator(".mx_Spinner")).not.toBeAttached(); + const resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(1); + }); + + test("should be able to navigate results via keyboard", async ({ page, app }) => { + const spotlight = await app.openSpotlight(); + await page.waitForTimeout(500); // wait for the dialog to settle + await spotlight.filter(Filter.People); + await spotlight.search("b"); + + let resultLocator = spotlight.results; + await expect(resultLocator).toHaveCount(2); + await expect(resultLocator.first()).toHaveAttribute("aria-selected", "true"); + await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false"); + + await spotlight.searchBox.press("ArrowDown"); + resultLocator = spotlight.results; + await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false"); + await expect(resultLocator.last()).toHaveAttribute("aria-selected", "true"); + + await spotlight.searchBox.press("ArrowDown"); + resultLocator = spotlight.results; + await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false"); + await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false"); + + await spotlight.searchBox.press("ArrowUp"); + resultLocator = spotlight.results; + await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false"); + await expect(resultLocator.last()).toHaveAttribute("aria-selected", "true"); + + await spotlight.searchBox.press("ArrowUp"); + resultLocator = spotlight.results; + await expect(resultLocator.first()).toHaveAttribute("aria-selected", "true"); + await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false"); + }); +}); diff --git a/playwright/e2e/threads/threads.spec.ts b/playwright/e2e/threads/threads.spec.ts new file mode 100644 index 000000000000..34fe287d4797 --- /dev/null +++ b/playwright/e2e/threads/threads.spec.ts @@ -0,0 +1,450 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { Layout } from "../../../src/settings/enums/Layout"; +import { test, expect } from "../../element-web-test"; + +test.describe("Threads", () => { + test.use({ + displayName: "Tom", + botCreateOpts: { + displayName: "BotBob", + autoAcceptInvites: true, + }, + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests + }); + }); + + // Flaky: https://github.com/vector-im/element-web/issues/26452 + test.skip("should be usable for a conversation", async ({ page, app, bot }) => { + const roomId = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + await page.goto("/#/room/" + roomId); + + // Around 200 characters + const MessageLong = + "Hello there. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt " + + "ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi"; + + const ThreadViewGroupSpacingStart = "56px"; // --ThreadView_group_spacing-start + // Exclude timestamp and read marker from snapshots + const mask = [page.locator(".mx_MessageTimestamp"), page.locator(".mx_MessagePanel_myReadMarker")]; + + const roomViewLocator = page.locator(".mx_RoomView_body"); + // User sends message + const textbox = roomViewLocator.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill("Hello Mr. Bot"); + await textbox.press("Enter"); + + // Wait for message to send, get its ID and save as @threadId + const threadId = await roomViewLocator + .locator(".mx_EventTile[data-scroll-tokens]") + .filter({ hasText: "Hello Mr. Bot" }) + .getAttribute("data-scroll-tokens"); + + // Bot starts thread + await bot.sendMessage(roomId, MessageLong, threadId); + + // User asserts timeline thread summary visible & clicks it + let locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText(MessageLong)).toBeAttached(); + await locator.click(); + + // Wait until the both messages are read + locator = page.locator(".mx_ThreadView .mx_EventTile_last[data-layout=group]"); + await expect(locator.locator(".mx_EventTile_line .mx_MTextBody").getByText(MessageLong)).toBeAttached(); + await expect(locator.locator(".mx_ReadReceiptGroup .mx_BaseAvatar")).toBeVisible(); + // Make sure the CSS style for spacing is applied to mx_EventTile_line on group/modern layout + await expect(locator.locator(".mx_EventTile_line")).toHaveCSS( + "padding-inline-start", + ThreadViewGroupSpacingStart, + ); + + // Take snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Initial_ThreadView_on_group_layout.png", { + mask: mask, + }); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toBeVisible(); + + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Initial_ThreadView_on_bubble_layout.png", { + mask: mask, + }); + + // Set the group layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + + locator = page.locator(".mx_ThreadView .mx_EventTile[data-layout='group'].mx_EventTile_last"); + // Wait until the messages are rendered + await expect(locator.locator(".mx_EventTile_line .mx_MTextBody").getByText(MessageLong)).toBeAttached(); + // Make sure the avatar inside ReadReceiptGroup is visible on the group layout + await expect(locator.locator(".mx_ReadReceiptGroup .mx_BaseAvatar")).toBeVisible(); + + // Enable the bubble layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + + locator = page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble'].mx_EventTile_last"); + // TODO: remove this after fixing the issue of ReadReceiptGroup being hidden on the bubble layout + // See: https://github.com/vector-im/element-web/issues/23569 + await expect(locator.locator(".mx_ReadReceiptGroup .mx_BaseAvatar")).toBeAttached(); + // Make sure the avatar inside ReadReceiptGroup is visible on bubble layout + // TODO: enable this after fixing the issue of ReadReceiptGroup being hidden on the bubble layout + // See: https://github.com/vector-im/element-web/issues/23569 + // expect(locator.locator(".mx_ReadReceiptGroup .mx_BaseAvatar")).toBeVisible(); + + // Re-enable the group layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + + // User responds in thread + locator = page.locator(".mx_ThreadView").getByRole("textbox", { name: "Send a message…" }); + await locator.fill("Test"); + await locator.press("Enter"); + + // User asserts summary was updated correctly + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("Test")).toBeAttached(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Check reactions and hidden events + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + // Enable hidden events to make the event for reaction displayed + await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); + + // User reacts to message instead + locator = page + .locator(".mx_ThreadView") + .locator(".mx_EventTile .mx_EventTile_line") + .filter({ hasText: "Hello there" }); + await locator.hover(); + await locator.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "React" }).click(); + + locator = page.locator(".mx_EmojiPicker"); + await locator.getByRole("textbox").fill("wave"); + await page.getByRole("gridcell", { name: "👋" }).click(); + + locator = page.locator(".mx_ThreadView"); + // Make sure the CSS style for spacing is applied to mx_ReactionsRow on group/modern layout + await expect(locator.locator(".mx_EventTile[data-layout=group] .mx_ReactionsRow")).toHaveCSS( + "margin-inline-start", + ThreadViewGroupSpacingStart, + ); + // Make sure the CSS style for spacing is applied to the hidden event on group/modern layout + await expect( + locator.locator( + ".mx_GenericEventListSummary[data-layout=group] .mx_EventTile_info.mx_EventTile_last " + + ".mx_EventTile_line", + ), + ).toHaveCSS("padding-inline-start", ThreadViewGroupSpacingStart); + + // Take snapshot of group layout (IRC layout is not available on ThreadView) + expect(page.locator(".mx_ThreadView")).toMatchScreenshot( + "ThreadView_with_reaction_and_a_hidden_event_on_group_layout.png", + { + mask: mask, + }, + ); + + // Enable bubble layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + + // Make sure the CSS style for spacing is applied to the hidden event on bubble layout + locator = page.locator( + ".mx_ThreadView .mx_GenericEventListSummary[data-layout=bubble] .mx_EventTile_info.mx_EventTile_last", + ); + expect(locator.locator(".mx_EventTile_line .mx_EventTile_content")) + // 76px: ThreadViewGroupSpacingStart + 14px + 6px + // 14px: avatar width + // See: _EventTile.pcss + .toHaveCSS("margin-inline-start", "76px"); + await expect(locator.locator(".mx_EventTile_line")) + // Make sure the margin is NOT applied to mx_EventTile_line + .toHaveCSS("margin-inline-start", "0px"); + + // Take snapshot of bubble layout + expect(page.locator(".mx_ThreadView")).toMatchScreenshot( + "ThreadView_with_reaction_and_a_hidden_event_on_bubble_layout.png", + { + mask: mask, + }, + ); + + // Disable hidden events + await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, false); + + // Reset to the group layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Check redactions + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + // User redacts their prior response + locator = page.locator(".mx_ThreadView .mx_EventTile .mx_EventTile_line").filter({ hasText: "Test" }); + await locator.hover(); + await locator.getByRole("button", { name: "Options" }).click(); + + await page.locator(".mx_IconizedContextMenu").getByRole("menuitem", { name: "Remove" }).click(); + locator = page.locator(".mx_TextInputDialog").getByRole("button", { name: "Remove" }); + await expect(locator).toHaveClass(/mx_Dialog_primary/); + await locator.click(); + + // Wait until the response is redacted + await expect( + page.locator(".mx_ThreadView").locator(".mx_EventTile_last .mx_EventTile_receiptSent"), + ).toBeVisible(); + + // Take snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='group']")).toBeVisible(); + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( + "ThreadView_with_redacted_messages_on_group_layout.png", + { + mask: mask, + }, + ); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toBeVisible(); + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( + "ThreadView_with_redacted_messages_on_bubble_layout.png", + { + mask: mask, + }, + ); + + // Set the group layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + + // User asserts summary was updated correctly + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText(MessageLong)).toBeAttached(); + + // User closes right panel after clicking back to thread list + locator = page.locator(".mx_ThreadPanel"); + locator.getByRole("button", { name: "Threads" }).click(); + locator.getByRole("button", { name: "Close" }).click(); + + // Bot responds to thread + await bot.sendMessage(roomId, "How are things?", threadId); + + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached(); + + locator = page.getByRole("button", { name: "Threads" }); + await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/); // User asserts thread list unread indicator + await locator.click(); // User opens thread list + + // User asserts thread with correct root & latest events & unread dot + locator = page.locator(".mx_ThreadPanel .mx_EventTile_last"); + await expect(locator.locator(".mx_EventTile_body").getByText("Hello Mr. Bot")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached(); + // Check the number of the replies + await expect(locator.locator(".mx_ThreadPanel_replies_amount").getByText("2")).toBeAttached(); + // Make sure the notification dot is visible + await expect(locator.locator(".mx_NotificationBadge_visible")).toBeVisible(); + // User opens thread via threads list + await locator.locator(".mx_EventTile_line").click(); + + // User responds & asserts + locator = page.locator(".mx_ThreadView").getByRole("textbox", { name: "Send a message…" }); + await locator.fill("Great!"); + await locator.press("Enter"); + + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("Great!")).toBeAttached(); + + // User edits & asserts + locator = page.locator(".mx_ThreadView .mx_EventTile_last"); + await expect(locator.getByText("Great!")).toBeAttached(); + await locator.locator(".mx_EventTile_line").hover(); + await locator.locator(".mx_EventTile_line").getByRole("button", { name: "Edit" }).click(); + await locator.getByRole("textbox").fill(" How about yourself?{enter}"); + await locator.getByRole("textbox").press("Enter"); + + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); + await expect( + locator.locator(".mx_ThreadSummary_content").getByText("Great! How about yourself?"), + ).toBeAttached(); + + // User closes right panel + await page.locator(".mx_ThreadPanel").getByRole("button", { name: "Close" }).click(); + + // Bot responds to thread and saves the id of their message to @eventId + const { event_id: eventId } = await bot.sendMessage(roomId, threadId, "I'm very good thanks"); + + // User asserts + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("I'm very good thanks")).toBeAttached(); + + // Bot edits their latest event + await bot.sendMessage(roomId, { + "body": "* I'm very good thanks :)", + "msgtype": "m.text", + "m.new_content": { + body: "I'm very good thanks :)", + msgtype: "m.text", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: eventId, + }, + }); + + // User asserts + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("I'm very good thanks :)")).toBeAttached(); + }); + + test.describe("with larger viewport", async () => { + // Increase viewport size so that voice messages fit + test.use({ viewport: { width: 1280, height: 720 } }); + + test.beforeEach(async ({ page }) => { + // Increase right-panel size, so that voice messages fit + await page.addInitScript(() => { + window.localStorage.setItem("mx_rhs_size", "600"); + }); + }); + + test("can send voice messages", async ({ page, app, user }) => { + // Increase right-panel size, so that voice messages fit + await page.evaluate(() => { + window.localStorage.setItem("mx_rhs_size", "600"); + }); + + const roomId = await app.client.createRoom({}); + await page.goto("/#/room/" + roomId); + + // Send message + const locator = page.locator(".mx_RoomView_body"); + await locator.getByRole("textbox", { name: "Send a message…" }).fill("Hello Mr. Bot"); + await locator.getByRole("textbox", { name: "Send a message…" }).press("Enter"); + // Create thread + const locator2 = locator.locator(".mx_EventTile[data-scroll-tokens]").filter({ hasText: "Hello Mr. Bot" }); + await locator2.hover(); + await locator2.getByRole("button", { name: "Reply in thread" }).click(); + + await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1); + + (await app.openMessageComposerOptions(true)).getByRole("menuitem", { name: "Voice Message" }).click(); + await page.waitForTimeout(3000); + await app.getComposer(true).getByRole("button", { name: "Send voice message" }).click(); + await expect(page.locator(".mx_ThreadView .mx_MVoiceMessageBody")).toHaveCount(1); + }); + }); + + test("should send location and reply to the location on ThreadView", async ({ page, app, bot }) => { + const roomId = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + await page.goto("/#/room/" + roomId); + + // Exclude timestamp, read marker, and mapboxgl-map from snapshots + const css = + ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .mapboxgl-map { visibility: hidden !important; }"; + + let locator = page.locator(".mx_RoomView_body"); + // User sends message + let textbox = locator.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill("Hello Mr. Bot"); + await textbox.press("Enter"); + // Wait for message to send, get its ID and save as @threadId + const threadId = await locator + .locator(".mx_EventTile[data-scroll-tokens]") + .filter({ hasText: "Hello Mr. Bot" }) + .getAttribute("data-scroll-tokens"); + + // Bot starts thread + await bot.sendMessage(roomId, "Hello there", threadId); + + // User clicks thread summary + await page.locator(".mx_RoomView_body .mx_ThreadSummary").click(); + + // User sends location on ThreadView + await expect(page.locator(".mx_ThreadView")).toBeAttached(); + await (await app.openMessageComposerOptions(true)).getByRole("menuitem", { name: "Location" }).click(); + await page.getByTestId(`share-location-option-Pin`).click(); + await page.locator("#mx_LocationPicker_map").click(); + await page.getByRole("button", { name: "Share location" }).click(); + await expect(page.locator(".mx_ThreadView .mx_EventTile_last .mx_MLocationBody")).toBeAttached({ + timeout: 10000, + }); + + // User replies to the location + locator = page.locator(".mx_ThreadView"); + await locator.locator(".mx_EventTile_last").hover(); + await locator.locator(".mx_EventTile_last").getByRole("button", { name: "Reply" }).click(); + textbox = locator.getByRole("textbox", { name: "Reply to thread…" }); + await textbox.fill("Please come here"); + await textbox.press("Enter"); + // Wait until the reply is sent + await expect(locator.locator(".mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible(); + + // Take a snapshot of reply to the shared location + await page.addStyleTag({ content: css }); + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Reply_to_the_location_on_ThreadView.png"); + }); + + test("right panel behaves correctly", async ({ page, app, user }) => { + // Create room + const roomId = await app.client.createRoom({}); + await page.goto("/#/room/" + roomId); + + // Send message + let locator = page.locator(".mx_RoomView_body"); + let textbox = locator.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill("Hello Mr. Bot"); + await textbox.press("Enter"); + // Create thread + const locator2 = locator.locator(".mx_EventTile[data-scroll-tokens]").filter({ hasText: "Hello Mr. Bot" }); + await locator2.hover(); + await locator2.getByRole("button", { name: "Reply in thread" }).click(); + await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1); + + // Send message to thread + locator = page.locator(".mx_ThreadPanel"); + textbox = locator.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill("Hello Mr. User"); + await textbox.press("Enter"); + await expect(locator.locator(".mx_EventTile_last").getByText("Hello Mr. User")).toBeAttached(); + // Close thread + await locator.getByRole("button", { name: "Close" }).click(); + + // Open existing thread + locator = page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: "Hello Mr. Bot" }); + await locator.hover(); + await locator.getByRole("button", { name: "Reply in thread" }).click(); + await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1); + + locator = page.locator(".mx_BaseCard"); + await expect(locator.locator(".mx_EventTile").first().getByText("Hello Mr. Bot")).toBeAttached(); + await expect(locator.locator(".mx_EventTile").last().getByText("Hello Mr. User")).toBeAttached(); + }); +}); diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts new file mode 100644 index 000000000000..32cc14bd4615 --- /dev/null +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -0,0 +1,1130 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as fs from "node:fs"; + +import type { Locator, Page } from "@playwright/test"; +import type { ISendEventResponse, EventType, MsgType } from "matrix-js-sdk/src/matrix"; +import { test, expect } from "../../element-web-test"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { Layout } from "../../../src/settings/enums/Layout"; +import { Client } from "../../pages/client"; +import { ElementAppPage } from "../../pages/ElementAppPage"; +import { Bot } from "../../pages/bot"; + +// The avatar size used in the timeline +const AVATAR_SIZE = 30; +// The resize method used in the timeline +const AVATAR_RESIZE_METHOD = "crop"; + +const ROOM_NAME = "Test room"; +const OLD_AVATAR = fs.readFileSync("playwright/sample-files/riot.png"); +const NEW_AVATAR = fs.readFileSync("playwright/sample-files/element.png"); +const OLD_NAME = "Alan"; +const NEW_NAME = "Alan (away)"; + +const getEventTilesWithBodies = (page: Page): Locator => { + return page.locator(".mx_EventTile").filter({ has: page.locator(".mx_EventTile_body") }); +}; + +const expectDisplayName = async (e: Locator, displayName: string): Promise => { + await expect(e.locator(".mx_DisambiguatedProfile_displayName")).toHaveText(displayName); +}; + +const expectAvatar = async (cli: Client, e: Locator, avatarUrl: string): Promise => { + const size = await e.page().evaluate((size) => size * window.devicePixelRatio, AVATAR_SIZE); + const url = await cli.evaluate( + (client, { avatarUrl, size, resizeMethod }) => { + // eslint-disable-next-line no-restricted-properties + return client.mxcUrlToHttp(avatarUrl, size, size, resizeMethod); + }, + { avatarUrl, size, resizeMethod: AVATAR_RESIZE_METHOD }, + ); + await expect(e.locator(".mx_BaseAvatar img")).toHaveAttribute("src", url); +}; + +const sendEvent = async (client: Client, roomId: string, html = false): Promise => { + const content = { + msgtype: "m.text" as MsgType, + body: "Message", + format: undefined, + formatted_body: undefined, + }; + if (html) { + content.format = "org.matrix.custom.html"; + content.formatted_body = "Message"; + } + return client.sendEvent(roomId, null, "m.room.message" as EventType, content); +}; + +test.describe("Timeline", () => { + test.use({ + displayName: OLD_NAME, + room: async ({ app, user }, use) => { + const roomId = await app.client.createRoom({ name: ROOM_NAME }); + await use({ roomId }); + }, + }); + + let oldAvatarUrl: string; + let newAvatarUrl: string; + + test.describe("useOnlyCurrentProfiles", () => { + test.beforeEach(async ({ app, user }) => { + ({ content_uri: oldAvatarUrl } = await app.client.uploadContent(OLD_AVATAR, { type: "image/png" })); + await app.client.setAvatarUrl(oldAvatarUrl); + ({ content_uri: newAvatarUrl } = await app.client.uploadContent(NEW_AVATAR, { type: "image/png" })); + }); + + test("should show historical profiles if disabled", async ({ page, app, room }) => { + await app.settings.setValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, false); + await sendEvent(app.client, room.roomId); + await app.client.setDisplayName("Alan (away)"); + await app.client.setAvatarUrl(newAvatarUrl); + // XXX: If we send the second event too quickly, there won't be + // enough time for the client to register the profile change + await page.waitForTimeout(500); + await sendEvent(app.client, room.roomId); + await app.viewRoomByName(ROOM_NAME); + + const events = getEventTilesWithBodies(page); + await expect(events).toHaveCount(2); + await expectDisplayName(events.nth(0), OLD_NAME); + await expectAvatar(app.client, events.nth(0), oldAvatarUrl); + await expectDisplayName(events.nth(1), NEW_NAME); + await expectAvatar(app.client, events.nth(1), newAvatarUrl); + }); + + test("should not show historical profiles if enabled", async ({ page, app, room }) => { + await app.settings.setValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, true); + await sendEvent(app.client, room.roomId); + await app.client.setDisplayName(NEW_NAME); + await app.client.setAvatarUrl(newAvatarUrl); + // XXX: If we send the second event too quickly, there won't be + // enough time for the client to register the profile change + await page.waitForTimeout(500); + await sendEvent(app.client, room.roomId); + await app.viewRoomByName(ROOM_NAME); + + const events = getEventTilesWithBodies(page); + await expect(events).toHaveCount(2); + for (const e of await events.all()) { + await expectDisplayName(e, NEW_NAME); + await expectAvatar(app.client, e, newAvatarUrl); + } + }); + }); + + test.describe("configure room", () => { + test("should create and configure a room on IRC layout", async ({ page, app, room }) => { + await page.goto(`/#/room/${room.roomId}`); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + await expect( + page.locator( + ".mx_RoomView_body .mx_GenericEventListSummary[data-layout='irc'] .mx_GenericEventListSummary_summary", + { hasText: `${OLD_NAME} created and configured the room.` }, + ), + ).toBeVisible(); + + // wait for the date separator to appear to have a stable screenshot + await expect(page.locator(".mx_TimelineSeparator")).toHaveText("today"); + + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("configured-room-irc-layout.png"); + }); + + test("should have an expanded generic event list summary (GELS) on IRC layout", async ({ page, app, room }) => { + await page.goto(`/#/room/${room.roomId}`); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + + // Wait until configuration is finished + await expect( + page.locator( + ".mx_RoomView_body .mx_GenericEventListSummary[data-layout='irc'] .mx_GenericEventListSummary_summary", + { hasText: `${OLD_NAME} created and configured the room.` }, + ), + ).toBeVisible(); + + const gels = page.locator(".mx_GenericEventListSummary"); + // Click "expand" link button + await gels.getByRole("button", { name: "Expand" }).click(); + // Assert that the "expand" link button worked + await expect(gels.getByRole("button", { name: "Collapse" })).toBeVisible(); + + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-irc-layout.png", { + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }); + }); + + test("should have an expanded generic event list summary (GELS) on compact modern/group layout", async ({ + page, + app, + room, + }) => { + await page.goto(`/#/room/${room.roomId}`); + + // Set compact modern layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + await app.settings.setValue("useCompactLayout", null, SettingLevel.DEVICE, true); + + // Wait until configuration is finished + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: `${OLD_NAME} created and configured the room.`, + }), + ).toBeVisible(); + + const gels = page.locator(".mx_GenericEventListSummary"); + // Click "expand" link button + await gels.getByRole("button", { name: "Expand" }).click(); + // Assert that the "expand" link button worked + await expect(gels.getByRole("button", { name: "Collapse" })).toBeVisible(); + + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-modern-layout.png", { + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }); + }); + + test("should click 'collapse' on the first hovered info event line inside GELS on bubble layout", async ({ + page, + app, + room, + }) => { + // This test checks clickability of the "Collapse" link button, which had been covered with + // MessageActionBar's safe area - https://github.com/vector-im/element-web/issues/22864 + + await page.goto(`/#/room/${room.roomId}`); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + await expect( + page.locator( + ".mx_RoomView_body .mx_GenericEventListSummary[data-layout='bubble'] .mx_GenericEventListSummary_summary", + { hasText: `${OLD_NAME} created and configured the room.` }, + ), + ).toBeVisible(); + + const gels = page.locator(".mx_GenericEventListSummary"); + // Click "expand" link button + await gels.getByRole("button", { name: "Expand" }).click(); + // Assert that the "expand" link button worked + await expect(gels.getByRole("button", { name: "Collapse" })).toBeVisible(); + + // Make sure spacer is not visible on bubble layout + await expect( + page.locator(".mx_GenericEventListSummary[data-layout=bubble] .mx_GenericEventListSummary_spacer"), + ).not.toBeVisible(); // See: _GenericEventListSummary.pcss + + // Save snapshot of expanded generic event list summary on bubble layout + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-bubble-layout.png", { + // Exclude timestamp from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + }); + + // Click "collapse" link button on the first hovered info event line + const firstTile = gels.locator(".mx_GenericEventListSummary_unstyledList .mx_EventTile_info:first-of-type"); + await firstTile.hover(); + await expect(firstTile.getByRole("toolbar", { name: "Message Actions" })).toBeVisible(); + await gels.getByRole("button", { name: "Collapse" }).click(); + + // Assert that "collapse" link button worked + await expect(gels.getByRole("button", { name: "Expand" })).toBeVisible(); + + // Save snapshot of collapsed generic event list summary on bubble layout + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("collapsed-gels-bubble-layout.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + }); + + test("should add inline start margin to an event line on IRC layout", async ({ + page, + app, + room, + axe, + checkA11y, + }) => { + axe.disableRules("color-contrast"); + + await page.goto(`/#/room/${room.roomId}`); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + + // Wait until configuration is finished + await expect( + page.locator( + ".mx_RoomView_body .mx_GenericEventListSummary[data-layout='irc'] .mx_GenericEventListSummary_summary", + { hasText: `${OLD_NAME} created and configured the room.` }, + ), + ).toBeVisible(); + + // Click "expand" link button + await page.locator(".mx_GenericEventListSummary").getByRole("button", { name: "Expand" }).click(); + + // Check the event line has margin instead of inset property + // cf. _EventTile.pcss + // --EventTile_irc_line_info-margin-inline-start + // = calc(var(--name-width) + var(--icon-width) + 1 * var(--right-padding)) + // = 80 + 14 + 5 = 99px + + const firstEventLineIrc = page.locator( + ".mx_EventTile_info[data-layout=irc]:first-of-type .mx_EventTile_line", + ); + await expect(firstEventLineIrc).toHaveCSS("margin-inline-start", "99px"); + await expect(firstEventLineIrc).toHaveCSS("inset-inline-start", "0px"); + + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "event-line-inline-start-margin-irc-layout.png", + { + // Exclude timestamp and read marker from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }, + ); + await checkA11y(); + }); + }); + + test.describe("message displaying", () => { + const messageEdit = async (page: Page) => { + const line = page.locator(".mx_EventTile .mx_EventTile_line", { hasText: "Message" }); + await line.hover(); + await line.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }).click(); + await page.getByRole("textbox", { name: "Edit message" }).pressSequentially("Edit"); + await page.getByRole("textbox", { name: "Edit message" }).press("Enter"); + + // Assert that the edited message and the link button are found + // Regex patterns due to the edited date + await expect( + page.locator(".mx_EventTile .mx_EventTile_line", { hasText: "MessageEdit" }).getByRole("button", { + name: /Edited at .*? Click to view edits./, + }), + ).toBeVisible(); + }; + + test("should align generic event list summary with messages and emote on IRC layout", async ({ + page, + app, + room, + }) => { + // This test aims to check: + // 1. Alignment of collapsed GELS (generic event list summary) and messages + // 2. Alignment of expanded GELS and messages + // 3. Alignment of expanded GELS and placeholder of deleted message + // 4. Alignment of expanded GELS, placeholder of deleted message, and emote + + await page.goto(`/#/room/${room.roomId}`); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + + // Wait until configuration is finished + await expect( + page + .locator(".mx_GenericEventListSummary_summary") + .getByText(`${OLD_NAME} created and configured the room.`), + ).toBeVisible(); + + // Send messages + const composer = app.getComposerField(); + await composer.fill("Hello Mr. Bot"); + await composer.press("Enter"); + await composer.fill("Hello again, Mr. Bot"); + await composer.press("Enter"); + + // Make sure the second message was sent + await expect( + page.locator(".mx_RoomView_MessageList > .mx_EventTile_last .mx_EventTile_receiptSent"), + ).toBeVisible(); + + // 1. Alignment of collapsed GELS (generic event list summary) and messages + // Check inline start spacing of collapsed GELS + // See: _EventTile.pcss + // .mx_GenericEventListSummary[data-layout="irc"] > .mx_EventTile_line + // = var(--name-width) + var(--icon-width) + var(--MessageTimestamp-width) + 2 * var(--right-padding) + // = 80 + 14 + 46 + 2 * 5 + // = 150px + await expect(page.locator(".mx_GenericEventListSummary[data-layout=irc] > .mx_EventTile_line")).toHaveCSS( + "padding-inline-start", + "150px", + ); + // Check width and spacing values of elements in .mx_EventTile, which should be equal to 150px + // --right-padding should be applied + for (const locator of await page.locator(".mx_EventTile > a").all()) { + if (await locator.isVisible()) { + await expect(locator).toHaveCSS("margin-right", "5px"); + } + } + // --name-width width zero inline end margin should be applied + for (const locator of await page.locator(".mx_EventTile .mx_DisambiguatedProfile").all()) { + await expect(locator).toHaveCSS("width", "80px"); + await expect(locator).toHaveCSS("margin-inline-end", "0px"); + } + // --icon-width should be applied + for (const locator of await page.locator(".mx_EventTile .mx_EventTile_avatar > .mx_BaseAvatar").all()) { + await expect(locator).toHaveCSS("width", "14px"); + } + // var(--MessageTimestamp-width) should be applied + for (const locator of await page.locator(".mx_EventTile > a").all()) { + await expect(locator).toHaveCSS("min-width", "46px"); + } + // Record alignment of collapsed GELS and messages on messagePanel + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "collapsed-gels-and-messages-irc-layout.png", + { + // Exclude timestamp from snapshot of mx_MainSplit + mask: [page.locator(".mx_MessageTimestamp")], + }, + ); + + // 2. Alignment of expanded GELS and messages + // Click "expand" link button + await page.locator(".mx_GenericEventListSummary").getByRole("button", { name: "Expand" }).click(); + // Check inline start spacing of info line on expanded GELS + // See: _EventTile.pcss + // --EventTile_irc_line_info-margin-inline-start + // = 80 + 14 + 1 * 5 + await expect( + page.locator(".mx_EventTile[data-layout=irc].mx_EventTile_info:first-of-type .mx_EventTile_line"), + ).toHaveCSS("margin-inline-start", "99px"); + // Record alignment of expanded GELS and messages on messagePanel + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-and-messages-irc-layout.png", { + // Exclude timestamp from snapshot of mx_MainSplit + mask: [page.locator(".mx_MessageTimestamp")], + }); + + // 3. Alignment of expanded GELS and placeholder of deleted message + // Delete the second (last) message + const lastTile = page.locator(".mx_RoomView_MessageList > .mx_EventTile_last"); + await lastTile.hover(); + await lastTile.getByRole("button", { name: "Options" }).click(); + await page.getByRole("menuitem", { name: "Remove" }).click(); + // Confirm deletion + await page.locator(".mx_Dialog_buttons").getByRole("button", { name: "Remove" }).click(); + // Make sure the dialog was closed and the second (last) message was redacted + await expect(page.locator(".mx_Dialog")).not.toBeVisible(); + await expect(page.locator(".mx_GenericEventListSummary .mx_EventTile_last .mx_RedactedBody")).toBeVisible(); + await expect( + page.locator(".mx_GenericEventListSummary .mx_EventTile_last .mx_EventTile_receiptSent"), + ).toBeVisible(); + // Record alignment of expanded GELS and placeholder of deleted message on messagePanel + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-redaction-placeholder.png", { + // Exclude timestamp from snapshot of mx_MainSplit + mask: [page.locator(".mx_MessageTimestamp")], + }); + + // 4. Alignment of expanded GELS, placeholder of deleted message, and emote + // Send a emote + await page + .locator(".mx_RoomView_body") + .getByRole("textbox", { name: "Send a message…" }) + .fill("/me says hello to Mr. Bot"); + await page.locator(".mx_RoomView_body").getByRole("textbox", { name: "Send a message…" }).press("Enter"); + // Check inline start margin of its avatar + // Here --right-padding is for the avatar on the message line + // See: _IRCLayout.pcss + // .mx_IRCLayout .mx_EventTile_emote .mx_EventTile_avatar + // = calc(var(--name-width) + var(--icon-width) + 1 * var(--right-padding)) + // = 80 + 14 + 1 * 5 + await expect(page.locator(".mx_EventTile_emote .mx_EventTile_avatar")).toHaveCSS("margin-left", "99px"); + // Make sure emote was sent + await expect(page.locator(".mx_EventTile_last.mx_EventTile_emote .mx_EventTile_receiptSent")).toBeVisible(); + // Record alignment of expanded GELS, placeholder of deleted message, and emote + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-emote-irc-layout.png", { + // Exclude timestamp from snapshot of mx_MainSplit + mask: [page.locator(".mx_MessageTimestamp")], + }); + }); + + test("should render EventTiles on IRC, modern (group), and bubble layout", async ({ page, app, room }) => { + const screenshotOptions = { + // Hide because flaky - See https://github.com/vector-im/element-web/issues/24957 + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }; + + await sendEvent(app.client, room.roomId); + await sendEvent(app.client, room.roomId); // check continuation + await sendEvent(app.client, room.roomId); // check the last EventTile + + await page.goto(`/#/room/${room.roomId}`); + const composer = app.getComposerField(); + // Send a plain text message + await composer.fill("Hello"); + await composer.press("Enter"); + // Send a big emoji + await composer.fill("🏀"); + await composer.press("Enter"); + // Send an inline emoji + await composer.fill("This message has an inline emoji 👒"); + await composer.press("Enter"); + + await expect(page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒")).toBeVisible(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // IRC layout + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + + // Wait until configuration is finished + await expect( + page + .locator(".mx_GenericEventListSummary_summary") + .getByText(`${OLD_NAME} created and configured the room.`), + ).toBeVisible(); + + await app.timeline.scrollToBottom(); + await expect( + page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"), + ).toBeInViewport(); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "event-tiles-irc-layout.png", + screenshotOptions, + ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Group/modern layout + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + + // Check that the last EventTile is rendered + await app.timeline.scrollToBottom(); + await expect( + page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"), + ).toBeInViewport(); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "event-tiles-modern-layout.png", + screenshotOptions, + ); + + // Check the same thing for compact layout + await app.settings.setValue("useCompactLayout", null, SettingLevel.DEVICE, true); + + // Check that the last EventTile is rendered + await app.timeline.scrollToBottom(); + await expect( + page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"), + ).toBeInViewport(); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "event-tiles-compact-modern-layout.png", + screenshotOptions, + ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Message bubble layout + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + + await app.timeline.scrollToBottom(); + await expect( + page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"), + ).toBeInViewport(); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "event-tiles-bubble-layout.png", + screenshotOptions, + ); + }); + + test("should set inline start padding to a hidden event line", async ({ page, app, room }) => { + await sendEvent(app.client, room.roomId); + await page.goto(`/#/room/${room.roomId}`); + await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); + await expect( + page + .locator(".mx_GenericEventListSummary_summary") + .getByText(`${OLD_NAME} created and configured the room.`), + ).toBeVisible(); + + // Edit message + await messageEdit(page); + + // Click timestamp to highlight hidden event line + await page.locator(".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp").click(); + + // should not add inline start padding to a hidden event line on IRC layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + await expect( + page.locator(".mx_EventTile[data-layout=irc].mx_EventTile_info .mx_EventTile_line").first(), + ).toHaveCSS("padding-inline-start", "0px"); + + // Exclude timestamp and read marker from snapshot + const screenshotOptions = { + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }; + + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "hidden-event-line-zero-padding-irc-layout.png", + screenshotOptions, + ); + + // should add inline start padding to a hidden event line on modern layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + // calc(var(--EventTile_group_line-spacing-inline-start) + 20px) = 64 + 20 = 84px + await expect( + page.locator(".mx_EventTile[data-layout=group].mx_EventTile_info .mx_EventTile_line").first(), + ).toHaveCSS("padding-inline-start", "84px"); + + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "hidden-event-line-padding-modern-layout.png", + screenshotOptions, + ); + }); + + test("should click view source event toggle", async ({ page, app, room }) => { + // This test checks: + // 1. clickability of top left of view source event toggle + // 2. clickability of view source toggle on IRC layout + + // Exclude timestamp from snapshot + const screenshotOptions = { + mask: [page.locator(".mx_MessageTimestamp")], + }; + + await sendEvent(app.client, room.roomId); + await page.goto(`/#/room/${room.roomId}`); + await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); + await expect( + page + .locator(".mx_GenericEventListSummary_summary") + .getByText(OLD_NAME + " created and configured the room."), + ).toBeVisible(); + + // Edit message + await messageEdit(page); + + // 1. clickability of top left of view source event toggle + + // Click top left of the event toggle, which should not be covered by MessageActionBar's safe area + const viewSourceEventGroup = page.locator(".mx_EventTile_last[data-layout=group] .mx_ViewSourceEvent"); + await viewSourceEventGroup.hover(); + await viewSourceEventGroup + .getByRole("button", { name: "toggle event" }) + .click({ position: { x: 0, y: 0 } }); + + // Make sure the expand toggle works + const viewSourceEventExpanded = page.locator( + ".mx_EventTile_last[data-layout=group] .mx_ViewSourceEvent_expanded", + ); + await viewSourceEventExpanded.hover(); + const toggleEventButton = viewSourceEventExpanded.getByRole("button", { name: "toggle event" }); + // Check size and position of toggle on expanded view source event + // See: _ViewSourceEvent.pcss + await expect(toggleEventButton).toHaveCSS("height", "12px"); // --ViewSourceEvent_toggle-size + await expect(toggleEventButton).toHaveCSS("align-self", "flex-end"); + // Click again to collapse the source + await toggleEventButton.click({ position: { x: 0, y: 0 } }); + + // Make sure the collapse toggle works + await expect( + page.locator(".mx_EventTile_last[data-layout=group] .mx_ViewSourceEvent_expanded"), + ).not.toBeVisible(); + + // 2. clickability of view source toggle on IRC layout + + // Enable IRC layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + + // Hover the view source toggle on IRC layout + const viewSourceEventIrc = page.locator( + ".mx_GenericEventListSummary[data-layout=irc] .mx_EventTile .mx_ViewSourceEvent", + ); + await viewSourceEventIrc.hover(); + await expect(viewSourceEventIrc).toMatchScreenshot( + "hovered-hidden-event-line-irc-layout.png", + screenshotOptions, + ); + + // Click view source event toggle + await viewSourceEventIrc.getByRole("button", { name: "toggle event" }).click({ position: { x: 0, y: 0 } }); + + // Make sure the expand toggle worked + await expect(page.locator(".mx_EventTile[data-layout=irc] .mx_ViewSourceEvent_expanded")).toBeVisible(); + }); + + test("should render file size in kibibytes on a file tile", async ({ page, room }) => { + await page.goto(`/#/room/${room.roomId}`); + await expect( + page + .locator(".mx_GenericEventListSummary_summary") + .getByText(OLD_NAME + " created and configured the room."), + ).toBeVisible(); + + // Upload a file from the message composer + await page + .locator(".mx_MessageComposer_actions input[type='file']") + .setInputFiles("playwright/sample-files/matrix-org-client-versions.json"); + + // Click "Upload" button + await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click(); + + // Wait until the file is sent + await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible(); + await expect(page.locator(".mx_EventTile.mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible(); + + // Assert that the file size is displayed in kibibytes (1024 bytes), not kilobytes (1000 bytes) + // See: https://github.com/vector-im/element-web/issues/24866 + await expect( + page.locator(".mx_EventTile_last .mx_MFileBody_info_filename").getByText(/1.12 KB/), + ).toBeVisible(); + }); + + test("should render url previews", async ({ page, app, room, axe, checkA11y }) => { + axe.disableRules("color-contrast"); + + await page.route( + "**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*", + async (route) => { + await route.fulfill({ + path: "playwright/sample-files/riot.png", + }); + }, + ); + await page.route( + "**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*", + async (route) => { + await route.fulfill({ + json: { + "og:title": "Element Call", + "og:description": null, + "og:image:width": 48, + "og:image:height": 48, + "og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV", + "og:image:type": "image/png", + "matrix:image:size": 2121, + }, + }); + }, + ); + + const requestPromises: Promise[] = [ + page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"), + page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"), + ]; + + await app.client.sendMessage(room.roomId, "https://call.element.io/"); + await page.goto(`/#/room/${room.roomId}`); + + await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible(); + await Promise.all(requestPromises); + + await checkA11y(); + + await app.timeline.scrollToBottom(); + await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", { + // Exclude timestamp and read marker from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }); + }); + + test.describe("on search results panel", () => { + test("should highlight search result words regardless of formatting", async ({ page, app, room }) => { + await sendEvent(app.client, room.roomId); + await sendEvent(app.client, room.roomId, true); + await page.goto(`/#/room/${room.roomId}`); + + await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click(); + + await expect(page.locator(".mx_SearchBar")).toMatchScreenshot("search-bar-on-timeline.png"); + + await page.locator(".mx_SearchBar_input").getByRole("textbox").fill("Message"); + await page.locator(".mx_SearchBar_input").getByRole("textbox").press("Enter"); + + for (const locator of await page + .locator(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight") + .all()) { + await expect(locator).toBeVisible(); + } + await expect(page.locator(".mx_RoomView_searchResultsPanel")).toMatchScreenshot( + "highlighted-search-results.png", + ); + }); + + test("should render a fully opaque textual event", async ({ page, app, room }) => { + const stringToSearch = "Message"; // Same with string sent with sendEvent() + + await sendEvent(app.client, room.roomId); + + await page.goto(`/#/room/${room.roomId}`); + + // Open a room setting dialog + await page.getByRole("button", { name: "Room options" }).click(); + await page.getByRole("menuitem", { name: "Settings" }).click(); + + // Set a room topic to render a TextualEvent + await page.getByRole("textbox", { name: "Room Topic" }).type(`This is a room for ${stringToSearch}.`); + await page.getByRole("button", { name: "Save" }).click(); + + await app.closeDialog(); + + // Assert that the TextualEvent is rendered + await expect( + page.getByText(`${OLD_NAME} changed the topic to "This is a room for ${stringToSearch}.".`), + ).toHaveClass(/mx_TextualEvent/); + + // Display the room search bar + await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click(); + + // Search the string to display both the message and TextualEvent on search results panel + await page.locator(".mx_SearchBar").getByRole("textbox").fill(stringToSearch); + await page.locator(".mx_SearchBar").getByRole("textbox").press("Enter"); + + // On search results panel + const resultsPanel = page.locator(".mx_RoomView_searchResultsPanel"); + // Assert that contextual event tiles are translucent + for (const locator of await resultsPanel.locator(".mx_EventTile.mx_EventTile_contextual").all()) { + await expect(locator).toHaveCSS("opacity", "0.4"); + } + // Assert that the TextualEvent is fully opaque (visually solid). + for (const locator of await resultsPanel.locator(".mx_EventTile .mx_TextualEvent").all()) { + await expect(locator).toHaveCSS("opacity", "1"); + } + + await expect(page.locator(".mx_RoomView_searchResultsPanel")).toMatchScreenshot( + "search-results-with-TextualEvent.png", + ); + }); + }); + }); + + test.describe("message sending", () => { + const MESSAGE = "Hello world"; + const reply = "Reply"; + const viewRoomSendMessageAndSetupReply = async (page: Page, app: ElementAppPage, roomId: string) => { + // View room + await page.goto(`/#/room/${roomId}`); + + // Send a message + const composer = app.getComposerField(); + await composer.fill(MESSAGE); + await composer.press("Enter"); + + // Reply to the message + const lastTile = page.locator(".mx_EventTile_last"); + await expect(lastTile.getByText(MESSAGE)).toBeVisible(); + await lastTile.hover(); + await lastTile.getByRole("button", { name: "Reply", exact: true }).click(); + }; + + // For clicking the reply button on the last line + const clickButtonReply = async (page: Page): Promise => { + const lastTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last"); + await lastTile.hover(); + await lastTile.getByRole("button", { name: "Reply", exact: true }).click(); + }; + + test("can reply with a text message", async ({ page, app, room }) => { + await viewRoomSendMessageAndSetupReply(page, app, room.roomId); + + await app.getComposerField().fill(reply); + await app.getComposerField().press("Enter"); + + const eventTileLine = page.locator(".mx_RoomView_body .mx_EventTile_last .mx_EventTile_line"); + await expect(eventTileLine.locator(".mx_ReplyTile .mx_MTextBody").getByText(MESSAGE)).toBeVisible(); + await expect(eventTileLine.getByText(reply)).toHaveCount(1); + }); + + 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(); + await composerOptions.getByRole("menuitem", { name: "Voice Message" }).click(); + + // Record an empty message + await page.waitForTimeout(3000); + + const roomViewBody = page.locator(".mx_RoomView_body"); + await roomViewBody + .locator(".mx_MessageComposer") + .getByRole("button", { name: "Send voice message" }) + .click(); + + const lastEventTileLine = roomViewBody.locator(".mx_EventTile_last .mx_EventTile_line"); + await expect(lastEventTileLine.locator(".mx_ReplyTile .mx_MTextBody").getByText(MESSAGE)).toBeVisible(); + + await expect(lastEventTileLine.locator(".mx_MVoiceMessageBody")).toHaveCount(1); + }); + + test("should not be possible to send flag with regional emojis", async ({ page, app, room }) => { + await page.goto(`/#/room/${room.roomId}`); + + // Send a message + await app.getComposerField().pressSequentially(":regional_indicator_a"); + await page.locator(".mx_Autocomplete_Completion_title", { hasText: ":regional_indicator_a:" }).click(); + await app.getComposerField().pressSequentially(":regional_indicator_r"); + await page.locator(".mx_Autocomplete_Completion_title", { hasText: ":regional_indicator_r:" }).click(); + await app.getComposerField().pressSequentially(" :regional_indicator_z"); + await page.locator(".mx_Autocomplete_Completion_title", { hasText: ":regional_indicator_z:" }).click(); + await app.getComposerField().pressSequentially(":regional_indicator_a"); + await page.locator(".mx_Autocomplete_Completion_title", { hasText: ":regional_indicator_a:" }).click(); + await app.getComposerField().press("Enter"); + + await expect( + page.locator( + ".mx_RoomView_body .mx_EventTile .mx_EventTile_line .mx_MTextBody .mx_EventTile_bigEmoji > *", + ), + ).toHaveCount(4); + }); + + test("should display a reply chain", async ({ page, app, room, homeserver }) => { + const reply2 = "Reply again"; + + await page.goto(`/#/room/${room.roomId}`); + + // Wait until configuration is finished + await expect( + page + .locator(".mx_GenericEventListSummary_summary") + .getByText(OLD_NAME + " created and configured the room."), + ).toBeVisible(); + + // Create a bot "BotBob" and invite it + const bot = new Bot(page, homeserver, { + displayName: "BotBob", + autoAcceptInvites: false, + }); + await bot.prepareClient(); + await app.client.inviteUser(room.roomId, bot.credentials.userId); + await bot.joinRoom(room.roomId); + + // Make sure the bot joined the room + await expect( + page + .locator(".mx_GenericEventListSummary .mx_EventTile_info.mx_EventTile_last") + .getByText("BotBob joined the room"), + ).toBeVisible(); + + // Have bot send MESSAGE to roomId + await bot.sendMessage(room.roomId, MESSAGE); + + // Assert that MESSAGE is found + await expect(page.getByText(MESSAGE)).toBeVisible(); + + // Reply to the message + await clickButtonReply(page); + await app.getComposerField().fill(reply); + await app.getComposerField().press("Enter"); + + // Make sure 'reply' was sent + await expect(page.locator(".mx_RoomView_body .mx_EventTile_last").getByText(reply)).toBeVisible(); + + // Reply again to create a replyChain + await clickButtonReply(page); + await app.getComposerField().fill(reply2); + await app.getComposerField().press("Enter"); + + // Assert that 'reply2' was sent + await expect(page.locator(".mx_RoomView_body .mx_EventTile_last").getByText(reply2)).toBeVisible(); + + await expect(page.locator(".mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible(); + + // Exclude timestamp and read marker from snapshot + const screenshotOptions = { + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }; + + // Check the margin value of ReplyChains of EventTile at the bottom on IRC layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + for (const locator of await page.locator(".mx_EventTile_last[data-layout='irc'] .mx_ReplyChain").all()) { + await expect(locator).toHaveCSS("margin", "0px"); + } + + // Take a snapshot on IRC layout + // Note that because zero margin is applied to mx_ReplyChain, the left borders of two mx_ReplyChain + // components may seem to be connected to one. + await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot( + "event-tile-reply-chains-irc-layout.png", + screenshotOptions, + ); + + // Check the margin value of ReplyChains of EventTile at the bottom on group/modern layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + for (const locator of await page.locator(".mx_EventTile_last[data-layout='group'] .mx_ReplyChain").all()) { + await expect(locator).toHaveCSS("margin-bottom", "8px"); + } + + // Take a snapshot on modern layout + await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot( + "event-tile-reply-chains-irc-modern.png", + screenshotOptions, + ); + + // Check the margin value of ReplyChains of EventTile at the bottom on group/modern compact layout + await app.settings.setValue("useCompactLayout", null, SettingLevel.DEVICE, true); + for (const locator of await page.locator(".mx_EventTile_last[data-layout='group'] .mx_ReplyChain").all()) { + await expect(locator).toHaveCSS("margin-bottom", "4px"); + } + + // Take a snapshot on compact modern layout + await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot( + "event-tile-reply-chains-compact-modern-layout.png", + screenshotOptions, + ); + + // Check the margin value of ReplyChains of EventTile at the bottom on bubble layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + for (const locator of await page.locator(".mx_EventTile_last[data-layout='bubble'] .mx_ReplyChain").all()) { + await expect(locator).toHaveCSS("margin-bottom", "8px"); + } + + // Take a snapshot on bubble layout + await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot( + "event-tile-reply-chains-bubble-layout.png", + screenshotOptions, + ); + }); + + test("should send, reply, and display long strings without overflowing", async ({ + page, + app, + room, + homeserver, + }) => { + // Max 256 characters for display name + const LONG_STRING = + "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut " + + "et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut " + + "aliquip"; + + // Create a bot with a long display name + const bot = new Bot(page, homeserver, { + displayName: LONG_STRING, + autoAcceptInvites: false, + }); + await bot.prepareClient(); + + // Create another room with a long name, invite the bot, and open the room + const testRoomId = await app.client.createRoom({ name: LONG_STRING }); + await app.client.inviteUser(testRoomId, bot.credentials.userId); + await bot.joinRoom(testRoomId); + await page.goto(`/#/room/${testRoomId}`); + + // Wait until configuration is finished + await expect( + page + .locator(".mx_GenericEventListSummary_summary") + .getByText(OLD_NAME + " created and configured the room."), + ).toBeVisible(); + + // Set the display name to "LONG_STRING 2" in order to avoid screenshot tests from failing + // due to the generated random mxid being displayed inside the GELS summary. + await app.client.setDisplayName(`${LONG_STRING} 2`); + + // Have the bot send a long message + await bot.sendMessage(testRoomId, { + body: LONG_STRING, + msgtype: "m.text", + }); + + // Wait until the message is rendered + await expect( + page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText(LONG_STRING), + ).toBeVisible(); + + // Reply to the message + await clickButtonReply(page); + await app.getComposerField().fill(reply); + await app.getComposerField().press("Enter"); + + // Make sure the reply tile is rendered + const eventTileLine = page.locator(".mx_EventTile_last .mx_EventTile_line"); + await expect(eventTileLine.locator(".mx_ReplyTile .mx_MTextBody").getByText(LONG_STRING)).toBeVisible(); + + await expect(eventTileLine.getByText(reply)).toHaveCount(1); + + // Change the viewport size + await page.setViewportSize({ width: 1600, height: 1200 }); + + // Exclude timestamp and read marker from snapshot + const screenshotOptions = { + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }; + + // Make sure the strings do not overflow on IRC layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + // Scroll to the bottom to take a snapshot of the whole viewport + await app.timeline.scrollToBottom(); + // Assert that both avatar in the introduction and the last message are visible at the same time + await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible(); + const lastEventTileIrc = page.locator(".mx_EventTile_last[data-layout='irc']"); + await expect(lastEventTileIrc.locator(".mx_MTextBody").first()).toBeVisible(); + await expect(lastEventTileIrc.locator(".mx_EventTile_receiptSent")).toBeVisible(); // rendered at the bottom of EventTile + // Take a snapshot in IRC layout + await expect(page.locator(".mx_ScrollPanel")).toMatchScreenshot( + "long-strings-with-reply-irc-layout.png", + screenshotOptions, + ); + + // Make sure the strings do not overflow on modern layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + await app.timeline.scrollToBottom(); // Scroll again in case + await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible(); + const lastEventTileGroup = page.locator(".mx_EventTile_last[data-layout='group']"); + await expect(lastEventTileGroup.locator(".mx_MTextBody").first()).toBeVisible(); + await expect(lastEventTileGroup.locator(".mx_EventTile_receiptSent")).toBeVisible(); + await expect(page.locator(".mx_ScrollPanel")).toMatchScreenshot( + "long-strings-with-reply-modern-layout.png", + screenshotOptions, + ); + + // Make sure the strings do not overflow on bubble layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + await app.timeline.scrollToBottom(); // Scroll again in case + await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible(); + const lastEventTileBubble = page.locator(".mx_EventTile_last[data-layout='bubble']"); + await expect(lastEventTileBubble.locator(".mx_MTextBody").first()).toBeVisible(); + await expect(lastEventTileBubble.locator(".mx_EventTile_receiptSent")).toBeVisible(); + await expect(page.locator(".mx_ScrollPanel")).toMatchScreenshot( + "long-strings-with-reply-bubble-layout.png", + screenshotOptions, + ); + }); + }); +}); diff --git a/playwright/e2e/user-view/user-view.spec.ts b/playwright/e2e/user-view/user-view.spec.ts index 6e642991e266..eddc466fece0 100644 --- a/playwright/e2e/user-view/user-view.spec.ts +++ b/playwright/e2e/user-view/user-view.spec.ts @@ -25,7 +25,7 @@ test.describe("UserView", () => { test("should render the user view as expected", async ({ page, homeserver, user, bot }) => { await page.goto(`/#/user/${bot.credentials.userId}`); - const rightPanel = page.getByRole("complementary"); + const rightPanel = page.locator("#mx_RightPanel"); await expect(rightPanel.getByRole("heading", { name: bot.credentials.displayName, exact: true })).toBeVisible(); await expect(rightPanel.getByText("1 session")).toBeVisible(); await expect(rightPanel).toMatchScreenshot("user-info.png", { diff --git a/playwright/e2e/utils.ts b/playwright/e2e/utils.ts new file mode 100644 index 000000000000..30aff64dd8fa --- /dev/null +++ b/playwright/e2e/utils.ts @@ -0,0 +1,66 @@ +/* +Copyright 2023 Mikhail Aheichyk +Copyright 2023 Nordeck IT + Consulting GmbH. +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { uniqueId } from "lodash"; + +import type { Page } from "@playwright/test"; +import type { ClientEvent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { Client } from "../pages/client"; + +/** + * Resolves when room state matches predicate. + * @param page Page instance + * @param client Client instance that can be user or bot + * @param roomId room id to find room and check + * @param predicate defines condition that is used to check the room state + */ +export async function waitForRoom( + page: Page, + client: Client, + roomId: string, + predicate: (room: Room) => boolean, +): Promise { + const predicateId = uniqueId("waitForRoom"); + await page.exposeFunction(predicateId, predicate); + await client.evaluateHandle( + (matrixClient, { roomId, predicateId }) => { + return new Promise((resolve) => { + const room = matrixClient.getRoom(roomId); + + if (window[predicateId](room)) { + resolve(room); + return; + } + + function onEvent(ev: MatrixEvent) { + if (ev.getRoomId() !== roomId) return; + + if (window[predicateId](room)) { + matrixClient.removeListener("event" as ClientEvent, onEvent); + resolve(room); + } + } + + matrixClient.on("event" as ClientEvent, onEvent); + }); + }, + { roomId, predicateId }, + ); +} + +export const CommandOrControl = process.platform === "darwin" ? "Meta" : "Control"; diff --git a/playwright/e2e/widgets/events.spec.ts b/playwright/e2e/widgets/events.spec.ts new file mode 100644 index 000000000000..a336bd2cfa18 --- /dev/null +++ b/playwright/e2e/widgets/events.spec.ts @@ -0,0 +1,176 @@ +/* +Copyright 2022 Mikhail Aheichyk +Copyright 2022 Nordeck IT + Consulting GmbH. +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test } from "../../element-web-test"; +import { waitForRoom } from "../utils"; + +const DEMO_WIDGET_ID = "demo-widget-id"; +const DEMO_WIDGET_NAME = "Demo Widget"; +const DEMO_WIDGET_TYPE = "demo"; +const ROOM_NAME = "Demo"; + +const DEMO_WIDGET_HTML = ` + + + Demo Widget + + + + + + +`; + +test.describe("Widget Events", () => { + test.use({ + displayName: "Mike", + botCreateOpts: { displayName: "Bot", autoAcceptInvites: true }, + }); + + let demoWidgetUrl: string; + test.beforeEach(async ({ webserver }) => { + demoWidgetUrl = webserver.start(DEMO_WIDGET_HTML); + }); + + test("should be updated if user is re-invited into the room with updated state event", async ({ + page, + app, + user, + bot, + }) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + invite: [bot.credentials.userId], + }); + + // setup widget via state event + await app.client.sendStateEvent( + roomId, + "im.vector.modular.widgets", + { + id: DEMO_WIDGET_ID, + creatorUserId: "somebody", + type: DEMO_WIDGET_TYPE, + name: DEMO_WIDGET_NAME, + url: demoWidgetUrl, + }, + DEMO_WIDGET_ID, + ); + + // set initial layout + await app.client.sendStateEvent( + roomId, + "io.element.widgets.layout", + { + widgets: { + [DEMO_WIDGET_ID]: { + container: "top", + index: 1, + width: 100, + height: 0, + }, + }, + }, + "", + ); + + // open the room + await app.viewRoomByName(ROOM_NAME); + + // approve capabilities + await page.locator(".mx_WidgetCapabilitiesPromptDialog").getByRole("button", { name: "Approve" }).click(); + + // bot creates a new room with 'm.room.topic' + const roomNew = await bot.createRoom({ + name: "New room", + initial_state: [ + { + type: "m.room.topic", + state_key: "", + content: { + topic: "topic initial", + }, + }, + ], + }); + + await bot.inviteUser(roomNew, user.userId); + + // widget should receive 'm.room.topic' event after invite + await waitForRoom(page, app.client, roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "net.widget_echo" && + e.getContent().type === "m.room.topic" && + e.getContent().content.topic === "topic initial", + ); + }); + + // update the topic + await bot.sendStateEvent( + roomNew, + "m.room.topic", + { + topic: "topic updated", + }, + "", + ); + + await bot.inviteUser(roomNew, user.userId); + + // widget should receive updated 'm.room.topic' event after re-invite + await waitForRoom(page, app.client, roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "net.widget_echo" && + e.getContent().type === "m.room.topic" && + e.getContent().content.topic === "topic updated", + ); + }); + }); +}); diff --git a/playwright/e2e/widgets/layout.spec.ts b/playwright/e2e/widgets/layout.spec.ts new file mode 100644 index 000000000000..a5dd856a9310 --- /dev/null +++ b/playwright/e2e/widgets/layout.spec.ts @@ -0,0 +1,119 @@ +/* +Copyright 2022 Oliver Sand +Copyright 2022 Nordeck IT + Consulting GmbH. +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; + +const ROOM_NAME = "Test Room"; +const WIDGET_ID = "fake-widget"; +const WIDGET_HTML = ` + + + Fake Widget + + + Hello World + + +`; + +test.describe("Widget Layout", () => { + test.use({ + displayName: "Sally", + }); + + let roomId: string; + let widgetUrl: string; + test.beforeEach(async ({ webserver, app, user }) => { + widgetUrl = webserver.start(WIDGET_HTML); + + roomId = await app.client.createRoom({ name: ROOM_NAME }); + + // setup widget via state event + await app.client.sendStateEvent( + roomId, + "im.vector.modular.widgets", + { + id: WIDGET_ID, + creatorUserId: "somebody", + type: "widget", + name: "widget", + url: widgetUrl, + }, + WIDGET_ID, + ); + + // set initial layout + await app.client.sendStateEvent( + roomId, + "io.element.widgets.layout", + { + widgets: { + [WIDGET_ID]: { + container: "top", + index: 1, + width: 100, + height: 0, + }, + }, + }, + "", + ); + + // open the room + await app.viewRoomByName(ROOM_NAME); + }); + + test("should be set properly", async ({ page }) => { + await expect(page.locator(".mx_AppsDrawer")).toMatchScreenshot("apps-drawer.png"); + }); + + test("manually resize the height of the top container layout", async ({ page }) => { + const iframe = page.locator('iframe[title="widget"]'); + expect((await iframe.boundingBox()).height).toBeLessThan(250); + + await page.locator(".mx_AppsDrawer_resizer_container_handle").hover(); + await page.mouse.down(); + await page.mouse.move(0, 550); + await page.mouse.up(); + + expect((await iframe.boundingBox()).height).toBeGreaterThan(400); + }); + + test("programmatically resize the height of the top container layout", async ({ page, app }) => { + const iframe = page.locator('iframe[title="widget"]'); + expect((await iframe.boundingBox()).height).toBeLessThan(250); + + await app.client.sendStateEvent( + roomId, + "io.element.widgets.layout", + { + widgets: { + [WIDGET_ID]: { + container: "top", + index: 1, + width: 100, + height: 500, + }, + }, + }, + "", + ); + + await expect.poll(async () => (await iframe.boundingBox()).height).toBeGreaterThan(400); + }); +}); diff --git a/cypress/e2e/widgets/stickers.spec.ts b/playwright/e2e/widgets/stickers.spec.ts similarity index 52% rename from cypress/e2e/widgets/stickers.spec.ts rename to playwright/e2e/widgets/stickers.spec.ts index d3e08f8405c0..37aaea58ceac 100644 --- a/cypress/e2e/widgets/stickers.spec.ts +++ b/playwright/e2e/widgets/stickers.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; +import type { Page } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { ElementAppPage } from "../../pages/ElementAppPage"; const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker"; const STICKER_PICKER_WIDGET_NAME = "Fake Stickers"; @@ -33,7 +33,7 @@ const STICKER_MESSAGE = JSON.stringify({ content: { body: STICKER_NAME, msgtype: "m.sticker", - url: "mxc://somewhere", + url: "mxc://localhost/somewhere", }, }, requestId: "1", @@ -66,108 +66,86 @@ const WIDGET_HTML = ` `; -function openStickerPicker() { - cy.openMessageComposerOptions().findByRole("menuitem", { name: "Sticker" }).click(); +async function openStickerPicker(app: ElementAppPage) { + const options = await app.openMessageComposerOptions(); + await options.getByRole("menuitem", { name: "Sticker" }).click(); } -function sendStickerFromPicker() { - // Note: Until https://github.com/cypress-io/cypress/issues/136 is fixed we will need - // to use `chromeWebSecurity: false` in our cypress config. Not even cy.origin() can - // break into the iframe for us :( - cy.accessIframe(`iframe[title="${STICKER_PICKER_WIDGET_NAME}"]`).within({}, () => { - cy.get("#sendsticker").should("exist").click(); - }); +async function sendStickerFromPicker(page: Page) { + const iframe = page.frameLocator(`iframe[title="${STICKER_PICKER_WIDGET_NAME}"]`); + await iframe.locator("#sendsticker").click(); // Sticker picker should close itself after sending. - cy.get(".mx_AppTileFullWidth#stickers").should("not.exist"); + await expect(page.locator(".mx_AppTileFullWidth#stickers")).not.toBeVisible(); } -function expectTimelineSticker(roomId: string) { +async function expectTimelineSticker(page: Page, roomId: string) { // Make sure it's in the right room - cy.get(".mx_EventTile_sticker > a").should("have.attr", "href").and("include", `/${roomId}/`); + await expect(page.locator(".mx_EventTile_sticker > a")).toHaveAttribute("href", new RegExp(`/${roomId}/`)); // Make sure the image points at the sticker image. We will briefly show it // using the thumbnail URL, but as soon as that fails, we will switch to the // download URL. - cy.get(`img[alt="${STICKER_NAME}"][src*="download/somewhere"]`).should("exist"); + await expect(page.locator(`img[alt="${STICKER_NAME}"]`)).toHaveAttribute( + "src", + new RegExp("/download/localhost/somewhere"), + ); } -describe("Stickers", () => { +test.describe("Stickers", () => { + test.use({ + displayName: "Sally", + }); + // We spin up a web server for the sticker picker so that we're not testing to see if // sysadmins can deploy sticker pickers on the same Element domain - we actually want // to make sure that cross-origin postMessage works properly. This makes it difficult // to write the test though, as we have to juggle iframe logistics. // // See sendStickerFromPicker() for more detail on iframe comms. - let stickerPickerUrl: string; - let homeserver: HomeserverInstance; - let userId: string; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Sally").then((user) => (userId = user.userId)); - }); - cy.serveHtmlFile(WIDGET_HTML).then((url) => { - stickerPickerUrl = url; - }); + test.beforeEach(async ({ webserver }) => { + stickerPickerUrl = webserver.start(WIDGET_HTML); }); - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); + test("should send a sticker to multiple rooms", async ({ page, app, user }) => { + const roomId1 = await app.client.createRoom({ name: ROOM_NAME_1 }); + const roomId2 = await app.client.createRoom({ name: ROOM_NAME_2 }); - it("should send a sticker to multiple rooms", () => { - cy.createRoom({ - name: ROOM_NAME_1, - }).as("roomId1"); - cy.createRoom({ - name: ROOM_NAME_2, - }).as("roomId2"); - cy.setAccountData("m.widgets", { + await app.client.setAccountData("m.widgets", { [STICKER_PICKER_WIDGET_ID]: { content: { type: "m.stickerpicker", name: STICKER_PICKER_WIDGET_NAME, url: stickerPickerUrl, - creatorUserId: userId, + creatorUserId: user.userId, }, - sender: userId, + sender: user.userId, state_key: STICKER_PICKER_WIDGET_ID, type: "m.widget", id: STICKER_PICKER_WIDGET_ID, }, - }).as("stickers"); - - cy.all([ - cy.get("@roomId1"), - cy.get("@roomId2"), - cy.get<{}>("@stickers"), // just want to wait for it to be set up - ]).then(([roomId1, roomId2]) => { - cy.viewRoomByName(ROOM_NAME_1); - cy.url().should("contain", `/#/room/${roomId1}`); - openStickerPicker(); - sendStickerFromPicker(); - expectTimelineSticker(roomId1); - - // Ensure that when we switch to a different room that the sticker - // goes to the right place - cy.viewRoomByName(ROOM_NAME_2); - cy.url().should("contain", `/#/room/${roomId2}`); - openStickerPicker(); - sendStickerFromPicker(); - expectTimelineSticker(roomId2); }); + + await app.viewRoomByName(ROOM_NAME_1); + await expect(page).toHaveURL(`/#/room/${roomId1}`); + await openStickerPicker(app); + await sendStickerFromPicker(page); + await expectTimelineSticker(page, roomId1); + + // Ensure that when we switch to a different room that the sticker + // goes to the right place + await app.viewRoomByName(ROOM_NAME_2); + await expect(page).toHaveURL(`/#/room/${roomId2}`); + await openStickerPicker(app); + await sendStickerFromPicker(page); + await expectTimelineSticker(page, roomId2); }); - it("should handle a sticker picker widget missing creatorUserId", () => { - cy.createRoom({ - name: ROOM_NAME_1, - }).as("roomId1"); - cy.setAccountData("m.widgets", { + test("should handle a sticker picker widget missing creatorUserId", async ({ page, app, user }) => { + const roomId1 = await app.client.createRoom({ name: ROOM_NAME_1 }); + + await app.client.setAccountData("m.widgets", { [STICKER_PICKER_WIDGET_ID]: { content: { type: "m.stickerpicker", @@ -175,19 +153,17 @@ describe("Stickers", () => { url: stickerPickerUrl, // No creatorUserId }, - sender: userId, + sender: user.userId, state_key: STICKER_PICKER_WIDGET_ID, type: "m.widget", id: STICKER_PICKER_WIDGET_ID, }, - }).as("stickers"); - - cy.all([cy.get("@roomId1"), cy.get<{}>("@stickers")]).then(([roomId1]) => { - cy.viewRoomByName(ROOM_NAME_1); - cy.url().should("contain", `/#/room/${roomId1}`); - openStickerPicker(); - sendStickerFromPicker(); - expectTimelineSticker(roomId1); }); + + await app.viewRoomByName(ROOM_NAME_1); + await expect(page).toHaveURL(`/#/room/${roomId1}`); + await openStickerPicker(app); + await sendStickerFromPicker(page); + await expectTimelineSticker(page, roomId1); }); }); diff --git a/playwright/e2e/widgets/widget-pip-close.spec.ts b/playwright/e2e/widgets/widget-pip-close.spec.ts new file mode 100644 index 000000000000..c8073a340514 --- /dev/null +++ b/playwright/e2e/widgets/widget-pip-close.spec.ts @@ -0,0 +1,169 @@ +/* +Copyright 2022 Mikhail Aheichyk +Copyright 2022 Nordeck IT + Consulting GmbH. +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { IWidget } from "matrix-widget-api/src/interfaces/IWidget"; +import type { MatrixEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { test, expect } from "../../element-web-test"; +import { Client } from "../../pages/client"; + +const DEMO_WIDGET_ID = "demo-widget-id"; +const DEMO_WIDGET_NAME = "Demo Widget"; +const DEMO_WIDGET_TYPE = "demo"; +const ROOM_NAME = "Demo"; + +const DEMO_WIDGET_HTML = ` + + + Demo Widget + + + + + + +`; + +// mostly copied from src/utils/WidgetUtils.waitForRoomWidget with small modifications +async function waitForRoomWidget(client: Client, widgetId: string, roomId: string, add: boolean): Promise { + await client.evaluate( + (matrixClient, { widgetId, roomId, add }) => { + return new Promise((resolve, reject) => { + function eventsInIntendedState(evList: MatrixEvent[]) { + const widgetPresent = evList.some((ev) => { + return ev.getContent() && ev.getContent()["id"] === widgetId; + }); + if (add) { + return widgetPresent; + } else { + return !widgetPresent; + } + } + + const room = matrixClient.getRoom(roomId); + + const startingWidgetEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); + if (eventsInIntendedState(startingWidgetEvents)) { + resolve(); + return; + } + + function onRoomStateEvents(ev: MatrixEvent) { + if (ev.getRoomId() !== roomId || ev.getType() !== "im.vector.modular.widgets") return; + + const currentWidgetEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); + + if (eventsInIntendedState(currentWidgetEvents)) { + matrixClient.removeListener("RoomState.events" as RoomStateEvent.Events, onRoomStateEvents); + resolve(); + } + } + + matrixClient.on("RoomState.events" as RoomStateEvent.Events, onRoomStateEvents); + }); + }, + { widgetId, roomId, add }, + ); +} + +test.describe("Widget PIP", () => { + test.use({ + displayName: "Mike", + botCreateOpts: { displayName: "Bot", autoAcceptInvites: false }, + }); + + let demoWidgetUrl: string; + test.beforeEach(async ({ webserver }) => { + demoWidgetUrl = webserver.start(DEMO_WIDGET_HTML); + }); + + for (const userRemove of ["leave", "kick", "ban"] as const) { + test(`should be closed on ${userRemove}`, async ({ page, app, bot, user }) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + invite: [bot.credentials.userId], + }); + + // sets bot to Admin and user to Moderator + await app.client.sendStateEvent(roomId, "m.room.power_levels", { + users: { + [user.userId]: 50, + [bot.credentials.userId]: 100, + }, + }); + + // bot joins the room + await bot.joinRoom(roomId); + + // setup widget via state event + const content: IWidget = { + id: DEMO_WIDGET_ID, + creatorUserId: "somebody", + type: DEMO_WIDGET_TYPE, + name: DEMO_WIDGET_NAME, + url: demoWidgetUrl, + }; + await app.client.sendStateEvent(roomId, "im.vector.modular.widgets", content, DEMO_WIDGET_ID); + + // open the room + await app.viewRoomByName(ROOM_NAME); + + // wait for widget state event + await waitForRoomWidget(app.client, DEMO_WIDGET_ID, roomId, true); + + // activate widget in pip mode + await page.evaluate( + ({ widgetId, roomId }) => { + window.mxActiveWidgetStore.setWidgetPersistence(widgetId, roomId, true); + }, + { + widgetId: DEMO_WIDGET_ID, + roomId, + }, + ); + + // checks that pip window is opened + await expect(page.locator(".mx_WidgetPip")).toBeVisible(); + + // checks that widget is opened in pip + const iframe = page.frameLocator(`iframe[title="${DEMO_WIDGET_NAME}"]`); + await expect(iframe.locator("#demo")).toBeVisible(); + + const userId = user.userId; + if (userRemove == "leave") { + await app.client.leave(roomId); + } else if (userRemove == "kick") { + await bot.kick(roomId, userId); + } else if (userRemove == "ban") { + await bot.ban(roomId, userId); + } + + // checks that pip window is closed + await expect(iframe.locator(".mx_WidgetPip")).not.toBeVisible(); + }); + } +}); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 971dd11d11c9..aceaa2b51bb0 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -17,6 +17,7 @@ limitations under the License. import { test as base, expect as baseExpect, Locator, Page, ExpectMatcherState, ElementHandle } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; import _ from "lodash"; +import { basename } from "node:path"; import type mailhog from "mailhog"; import type { IConfigOptions } from "../src/IConfigOptions"; @@ -29,6 +30,7 @@ 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"; const CONFIG_JSON: Partial = { @@ -43,6 +45,11 @@ const CONFIG_JSON: Partial = { }, }, + // The default language is set here for test consistency + setting_defaults: { + language: "en-GB", + }, + // the location tests want a map style url. map_style_url: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", }; @@ -66,6 +73,16 @@ export const test = base.extend< homeserver: HomeserverInstance; oAuthServer: { port: number }; credentials: CredentialsWithDisplayName; + + /** + * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`}, + * but adds an initScript which will populate localStorage with the user's details from + * {@link #credentials} and {@link #homeserver}. + * + * Similar to {@link #user}, but doesn't load the app. + */ + pageWithCredentials: Page; + user: CredentialsWithDisplayName; displayName?: string; app: ElementAppPage; @@ -76,28 +93,34 @@ 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; } >({ cryptoBackend: ["legacy", { option: true }], config: CONFIG_JSON, - page: async ({ context, page, config, cryptoBackend }, use) => { + page: async ({ context, page, config, cryptoBackend, labsFlags }, use) => { await context.route(`http://localhost:8080/config.json*`, async (route) => { const json = { ...CONFIG_JSON, ...config }; + json["features"] = { + ...json["features"], + // Enable the lab features + ...labsFlags.reduce((obj, flag) => { + obj[flag] = true; + return obj; + }, {}), + }; if (cryptoBackend === "rust") { - json["features"] = { - ...json["features"], - feature_rust_crypto: true, - }; + json.features.feature_rust_crypto = true; } await route.fulfill({ json }); }); - await use(page); }, startHomeserverOpts: "default", - homeserver: async ({ request, startHomeserverOpts: opts }, use) => { + homeserver: async ({ request, startHomeserverOpts: opts }, use, testInfo) => { if (typeof opts === "string") { opts = { template: opts }; } @@ -116,7 +139,16 @@ export const test = base.extend< } await use(await server.start(opts)); - await server.stop(); + const logs = await server.stop(); + + if (testInfo.status !== "passed") { + for (const path of logs) { + await testInfo.attach(`homeserver-${basename(path)}`, { + path, + contentType: "text/plain", + }); + } + } }, // eslint-disable-next-line no-empty-pattern oAuthServer: async ({}, use) => { @@ -140,7 +172,9 @@ export const test = base.extend< displayName, }); }, - user: async ({ page, homeserver, credentials }, use) => { + labsFlags: [], + + pageWithCredentials: async ({ page, homeserver, credentials }, use) => { await page.addInitScript( ({ baseUrl, credentials }) => { // Seed the localStorage with the required credentials @@ -157,10 +191,12 @@ export const test = base.extend< }, { baseUrl: homeserver.config.baseUrl, credentials }, ); - await page.goto("/"); + await use(page); + }, + user: async ({ pageWithCredentials: page, credentials }, use) => { + await page.goto("/"); await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); - await use(credentials); }, @@ -191,12 +227,31 @@ export const test = base.extend< }, botCreateOpts: {}, - bot: async ({ page, homeserver, botCreateOpts }, use) => { + bot: async ({ page, homeserver, botCreateOpts, user }, use) => { const bot = new Bot(page, homeserver, botCreateOpts); await bot.prepareClient(); // eagerly register the bot await use(bot); }, + 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(); @@ -206,7 +261,17 @@ export const test = base.extend< }); export const expect = baseExpect.extend({ - async toMatchScreenshot(this: ExpectMatcherState, receiver: Page | Locator, ...args) { + async toMatchScreenshot( + this: ExpectMatcherState, + receiver: Page | Locator, + name?: `${string}.png`, + options?: { + mask?: Array; + omitBackground?: boolean; + timeout?: number; + css?: string; + }, + ) { const page = "page" in receiver ? receiver.page() : receiver; // We add a custom style tag before taking screenshots @@ -228,16 +293,17 @@ export const expect = baseExpect.extend({ .mx_ReplyChain { border-left-color: var(--cpd-color-blue-1200) !important; } + /* Use monospace font for timestamp for consistent mask width */ + .mx_MessageTimestamp { + font-family: Inconsolata !important; + } + ${options?.css ?? ""} `, })) as ElementHandle; - await baseExpect(receiver).toHaveScreenshot(...args); + await baseExpect(receiver).toHaveScreenshot(name, options); await style.evaluate((tag) => tag.remove()); return { pass: true, message: () => "", name: "toMatchScreenshot" }; }, }); - -test.use({ - permissions: ["clipboard-read"], -}); diff --git a/playwright/global.d.ts b/playwright/global.d.ts index c537d0a142e0..166bfbe9931a 100644 --- a/playwright/global.d.ts +++ b/playwright/global.d.ts @@ -25,6 +25,9 @@ declare global { mxSettingsStore: { setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise; }; + mxActiveWidgetStore: { + setWidgetPersistence(widgetId: string, roomId: string | null, val: boolean): void; + }; matrixcs: typeof Matrix; } } diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index 8d5b43f1d82f..be80cf628084 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -18,14 +18,15 @@ import { type Locator, type Page, expect } from "@playwright/test"; import { Settings } from "./settings"; import { Client } from "./client"; -import { Labs } from "./labs"; +import { Timeline } from "./timeline"; +import { Spotlight } from "./Spotlight"; export class ElementAppPage { - public constructor(private readonly page: Page) {} + public constructor(public readonly page: Page) {} - public labs = new Labs(this.page); public settings = new Settings(this.page); public client: Client = new Client(this.page); + public timeline: Timeline = new Timeline(this.page); /** * Open the top left user menu, returning a Locator to the resulting context menu. @@ -54,15 +55,6 @@ export class ElementAppPage { return await this.page.evaluate(() => navigator.clipboard.readText()); } - /** - * Find an open dialog by its title - */ - public async getDialogByTitle(title: string, timeout = 5000): Promise { - const dialog = this.page.locator(".mx_Dialog"); - await dialog.getByRole("heading", { name: title }).waitFor({ timeout }); - return dialog; - } - /** * Opens the given room by name. The room must be visible in the * room list, but the room list may be folded horizontally, and the @@ -91,6 +83,10 @@ export class ElementAppPage { .click(); } + public async viewRoomById(roomId: string): Promise { + await this.page.goto(`/#/room/${roomId}`); + } + /** * Get the composer element * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer @@ -100,6 +96,14 @@ export class ElementAppPage { return this.page.locator(`${panelClass} .mx_MessageComposer`); } + /** + * Get the composer input field + * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer + */ + public getComposerField(isRightPanel?: boolean): Locator { + return this.getComposer(isRightPanel).locator("[contenteditable]"); + } + /** * Open the message composer kebab menu * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer @@ -144,4 +148,10 @@ export class ElementAppPage { public async getClipboardText(): Promise { return this.page.evaluate("navigator.clipboard.readText()"); } + + public async openSpotlight(): Promise { + const spotlight = new Spotlight(this.page); + await spotlight.open(); + return spotlight; + } } diff --git a/playwright/pages/Spotlight.ts b/playwright/pages/Spotlight.ts new file mode 100644 index 000000000000..dcd4b73f85a2 --- /dev/null +++ b/playwright/pages/Spotlight.ts @@ -0,0 +1,71 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Locator, Page } from "@playwright/test"; +import { CommandOrControl } from "../e2e/utils"; + +export enum Filter { + People = "people", + PublicRooms = "public_rooms", +} + +export class Spotlight { + private root: Locator; + + constructor(private page: Page) {} + + public async open() { + this.root = this.page.locator('[role=dialog][aria-label="Search Dialog"]'); + const isSpotlightAlreadyOpen = !!(await this.root.count()); + if (isSpotlightAlreadyOpen) { + // Close dialog if it is already open + await this.page.keyboard.press(`${CommandOrControl}+KeyK`); + } + await this.page.keyboard.press(`${CommandOrControl}+KeyK`); + } + + public async filter(filter: Filter) { + let selector: string; + switch (filter) { + case Filter.People: + selector = "#mx_SpotlightDialog_button_startChat"; + break; + case Filter.PublicRooms: + selector = "#mx_SpotlightDialog_button_explorePublicRooms"; + break; + default: + selector = ".mx_SpotlightDialog_filter"; + break; + } + await this.root.locator(selector).click(); + } + + public async search(query: string) { + await this.searchBox.getByRole("textbox", { name: "Search" }).fill(query); + } + + public get searchBox() { + return this.root.locator(".mx_SpotlightDialog_searchBox"); + } + + public get results() { + return this.root.locator(".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option"); + } + + public get dialog() { + return this.root; + } +} diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index fd122680c43f..d6b729420cdb 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -18,8 +18,10 @@ import { JSHandle, Page } from "@playwright/test"; import { uniqueId } from "lodash"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import type { AddSecretStorageKeyOpts } from "matrix-js-sdk/src/secret-storage"; +import type { Logger } from "matrix-js-sdk/src/logger"; +import type { SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage"; import type { Credentials, HomeserverInstance } from "../plugins/homeserver"; +import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import { Client } from "./client"; export interface CreateBotOpts { @@ -60,14 +62,31 @@ const defaultCreateBotOptions = { bootstrapCrossSigning: true, } satisfies CreateBotOpts; +type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey }; + export class Bot extends Client { public credentials?: Credentials; + private handlePromise: Promise>; - constructor(page: Page, private homeserver: HomeserverInstance, private readonly opts: CreateBotOpts) { + constructor( + page: Page, + private homeserver: HomeserverInstance, + private readonly opts: CreateBotOpts, + ) { super(page); this.opts = Object.assign({}, defaultCreateBotOptions, opts); } + public setCredentials(credentials: Credentials): void { + if (this.credentials) throw new Error("Bot has already started"); + this.credentials = credentials; + } + + public async getRecoveryKey(): Promise { + const client = await this.getClientHandle(); + return client.evaluate((cli) => cli.__playwright_recovery_key); + } + private async getCredentials(): Promise { if (this.credentials) return this.credentials; // We want to pad the uniqueId but not the prefix @@ -82,9 +101,36 @@ export class Bot extends Client { return this.credentials; } - protected async getClientHandle(): Promise> { - return this.page.evaluateHandle( + protected async getClientHandle(): Promise> { + if (this.handlePromise) return this.handlePromise; + + this.handlePromise = this.page.evaluateHandle( async ({ homeserver, credentials, opts }) => { + function getLogger(loggerName: string): Logger { + const logger = { + getChild: (namespace: string) => getLogger(`${loggerName}:${namespace}`), + trace(...msg: any[]): void { + console.trace(loggerName, ...msg); + }, + debug(...msg: any[]): void { + console.debug(loggerName, ...msg); + }, + info(...msg: any[]): void { + console.info(loggerName, ...msg); + }, + warn(...msg: any[]): void { + console.warn(loggerName, ...msg); + }, + error(...msg: any[]): void { + console.error(loggerName, ...msg); + }, + } satisfies Logger; + + return logger as unknown as Logger; + } + + const logger = getLogger(`cypress bot ${credentials.userId}`); + const keys = {}; const getCrossSigningKey = (type: string) => { @@ -97,7 +143,11 @@ export class Bot extends Client { // Store the cached secret storage key and return it when `getSecretStorageKey` is called let cachedKey: { keyId: string; key: Uint8Array }; - const cacheSecretStorageKey = (keyId: string, keyInfo: AddSecretStorageKeyOpts, key: Uint8Array) => { + const cacheSecretStorageKey = ( + keyId: string, + keyInfo: SecretStorageKeyDescription, + key: Uint8Array, + ) => { cachedKey = { keyId, key, @@ -123,7 +173,8 @@ export class Bot extends Client { scheduler: new window.matrixcs.MatrixScheduler(), cryptoStore: new window.matrixcs.MemoryCryptoStore(), cryptoCallbacks, - }); + logger, + }) as ExtendedMatrixClient; if (opts.autoAcceptInvites) { cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { @@ -146,6 +197,10 @@ export class Bot extends Client { await cli.startClient(); if (opts.bootstrapCrossSigning) { + // XXX: workaround https://github.com/element-hq/element-web/issues/26755 + // wait for out device list to be available, as a proxy for the device keys having been uploaded. + await cli.getCrypto()!.getUserDeviceInfo([credentials.userId]); + await cli.getCrypto()!.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (func) => { await func({ @@ -180,5 +235,6 @@ export class Bot extends Client { opts: this.opts, }, ); + return this.handlePromise; } } diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index c1e4f7a9ed83..054b946845c9 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -17,6 +17,7 @@ limitations under the License. import { JSHandle, Page } from "@playwright/test"; import { PageFunctionOn } from "playwright-core/types/structs"; +import { Network } from "./network"; import type { IContent, ICreateRoomOpts, @@ -26,9 +27,15 @@ import type { MatrixEvent, ReceiptType, IRoomDirectoryOptions, + KnockRoomOpts, + Visibility, + UploadOpts, + Upload, } from "matrix-js-sdk/src/matrix"; +import { Credentials } from "../plugins/homeserver"; export class Client { + public network: Network; protected client: JSHandle; protected getClientHandle(): Promise> { @@ -46,6 +53,7 @@ export class Client { page.on("framenavigated", async () => { this.client = null; }); + this.network = new Network(page, this); } public evaluate( @@ -96,19 +104,32 @@ export class Client { } /** - * Send a message as a bot into a room + * Send a message into a room * @param roomId ID of the room to send the message into * @param content the event content to send + * @param threadId optional thread id */ - public async sendMessage(roomId: string, content: IContent): Promise { + public async sendMessage( + roomId: string, + content: IContent | string, + threadId: string | null = null, + ): Promise { + if (typeof content === "string") { + content = { + msgtype: "m.text", + body: content, + }; + } + const client = await this.prepareClient(); return client.evaluate( - (client, { roomId, content }) => { - return client.sendMessage(roomId, content); + (client, { roomId, content, threadId }) => { + return client.sendMessage(roomId, threadId, content); }, { roomId, content, + threadId, }, ); } @@ -177,13 +198,14 @@ export class Client { * Make this bot join a room by name * @param roomName Name of the room to join */ - public async joinRoomByName(roomName: string): Promise { + public async joinRoomByName(roomName: string): Promise { const client = await this.prepareClient(); - await client.evaluate( - (client, { roomName }) => { + return client.evaluate( + async (client, { roomName }) => { const room = client.getRooms().find((r) => r.getDefaultRoomName(client.getUserId()) === roomName); if (room) { - return client.joinRoom(room.roomId); + await client.joinRoom(room.roomId); + return room.roomId; } throw new Error(`Bot room join failed. Cannot find room '${roomName}'`); }, @@ -193,6 +215,17 @@ export class Client { ); } + /** + * Wait until next sync from this client + */ + public async waitForNextSync(): Promise { + await this.page.waitForResponse(async (response) => { + const accessToken = await this.evaluate((client) => client.getAccessToken()); + const authHeader = await response.request().headerValue("authorization"); + return response.url().includes("/sync") && authHeader.includes(accessToken); + }); + } + /** * Invites the given user to the given room. * @param roomId the id of the room to invite to @@ -206,6 +239,56 @@ export class Client { }); } + /** + * Knocks the given room. + * @param roomId the id of the room to knock + * @param opts the options to use when knocking + */ + public async knockRoom(roomId: string, opts?: KnockRoomOpts): Promise { + const client = await this.prepareClient(); + await client.evaluate((client, { roomId, opts }) => client.knockRoom(roomId, opts), { roomId, opts }); + } + + /** + * Kicks the given user from the given room. + * @param roomId the id of the room to kick from + * @param userId the id of the user to kick + * @param reason the reason for the kick + */ + public async kick(roomId: string, userId: string, reason?: string): Promise { + const client = await this.prepareClient(); + await client.evaluate((client, { roomId, userId, reason }) => client.kick(roomId, userId, reason), { + roomId, + userId, + reason, + }); + } + + /** + * Bans the given user from the given room. + * @param roomId the id of the room to ban from + * @param userId the id of the user to ban + * @param reason the reason for the ban + */ + public async ban(roomId: string, userId: string, reason?: string): Promise { + const client = await this.prepareClient(); + await client.evaluate((client, { roomId, userId, reason }) => client.ban(roomId, userId, reason), { + roomId, + userId, + reason, + }); + } + + /** + * Unban the given user from the given room. + * @param roomId the id of the room to unban from + * @param userId the id of the user to unban + */ + public async unban(roomId: string, userId: string): Promise { + const client = await this.prepareClient(); + await client.evaluate((client, { roomId, userId }) => client.unban(roomId, userId), { roomId, userId }); + } + /** * @param {MatrixEvent} event * @param {ReceiptType} receiptType @@ -227,8 +310,132 @@ export class Client { public async publicRooms(options?: IRoomDirectoryOptions): ReturnType { const client = await this.prepareClient(); - return await client.evaluate((client, options) => { + return client.evaluate((client, options) => { return client.publicRooms(options); }, options); } + + /** + * @param {string} name + * @param {module:client.callback} callback Optional. + * @return {Promise} Resolves: {} an empty object. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ + public async setDisplayName(name: string): Promise<{}> { + const client = await this.prepareClient(); + return client.evaluate(async (cli: MatrixClient, name) => cli.setDisplayName(name), name); + } + + /** + * @param {string} url + * @param {module:client.callback} callback Optional. + * @return {Promise} Resolves: {} an empty object. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ + public async setAvatarUrl(url: string): Promise<{}> { + const client = await this.prepareClient(); + return client.evaluate(async (cli: MatrixClient, url) => cli.setAvatarUrl(url), url); + } + + /** + * Upload a file to the media repository on the homeserver. + * + * @param {object} file The object to upload. On a browser, something that + * can be sent to XMLHttpRequest.send (typically a File). Under node.js, + * a Buffer, String or ReadStream. + */ + public async uploadContent(file: Buffer, opts?: UploadOpts): Promise> { + const client = await this.prepareClient(); + return client.evaluate( + async (cli: MatrixClient, { file, opts }) => cli.uploadContent(new Uint8Array(file), opts), + { + file: [...file], + opts, + }, + ); + } + + /** + * Boostraps cross-signing. + */ + public async bootstrapCrossSigning(credentials: Credentials): Promise { + const client = await this.prepareClient(); + return client.evaluate(async (client, credentials) => { + await client.getCrypto().bootstrapCrossSigning({ + authUploadDeviceSigningKeys: async (func) => { + await func({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: credentials.userId, + }, + password: credentials.password, + }); + }, + }); + }, credentials); + } + + /** + * Sets account data for the user. + * @param type The type of account data to set + * @param content The content to set + */ + public async setAccountData(type: string, content: IContent): Promise { + const client = await this.prepareClient(); + return client.evaluate( + async (client, { type, content }) => { + await client.setAccountData(type, content); + }, + { type, content }, + ); + } + + /** + * Sends a state event into the room. + * @param roomId ID of the room to send the event into + * @param eventType type of event to send + * @param content the event content to send + * @param stateKey the state key to use + */ + public async sendStateEvent( + roomId: string, + eventType: string, + content: IContent, + stateKey?: string, + ): Promise { + const client = await this.prepareClient(); + return client.evaluate( + async (client, { roomId, eventType, content, stateKey }) => { + return client.sendStateEvent(roomId, eventType, content, stateKey); + }, + { roomId, eventType, content, stateKey }, + ); + } + + /** + * Leaves the given room. + * @param roomId ID of the room to leave + */ + public async leave(roomId: string): Promise { + const client = await this.prepareClient(); + return client.evaluate(async (client, roomId) => { + await client.leave(roomId); + }, roomId); + } + + /** + * Sets the directory visibility for a room. + * @param roomId ID of the room to set the directory visibility for + * @param visibility The new visibility for the room + */ + public async setRoomDirectoryVisibility(roomId: string, visibility: Visibility): Promise { + const client = await this.prepareClient(); + return client.evaluate( + async (client, { roomId, visibility }) => { + await client.setRoomDirectoryVisibility(roomId, visibility); + }, + { roomId, visibility }, + ); + } } diff --git a/playwright/pages/labs.ts b/playwright/pages/labs.ts deleted file mode 100644 index 55bce18225df..000000000000 --- a/playwright/pages/labs.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Page } from "playwright-core"; - -export class Labs { - constructor(private page: Page) {} - - /** - * Enables a labs feature for an element session. - * @param feature labsFeature to enable (e.g. "feature_spotlight") - */ - public async enableLabsFeature(feature: string): Promise { - if (this.page.url() === "about:blank") { - await this.page.addInitScript((feature) => { - window.localStorage.setItem(`mx_labs_feature_${feature}`, "true"); - }, feature); - } else { - await this.page.evaluate((feature) => { - window.localStorage.setItem(`mx_labs_feature_${feature}`, "true"); - }, feature); - } - } -} diff --git a/playwright/pages/network.ts b/playwright/pages/network.ts new file mode 100644 index 000000000000..f53c32ad99a1 --- /dev/null +++ b/playwright/pages/network.ts @@ -0,0 +1,62 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Page, Request } from "@playwright/test"; +import type { Client } from "./client"; + +export class Network { + private isOffline = false; + private readonly setupPromise: Promise; + + constructor( + private page: Page, + private client: Client, + ) { + this.setupPromise = this.setupRoute(); + } + + /** + * Checks if the request is from the client associated with this network object. + * We do this so that other clients (eg: bots) are not affected by the network change. + */ + private async isRequestFromOurClient(request: Request): Promise { + const accessToken = await this.client.evaluate((client) => client.getAccessToken()); + const authHeader = await request.headerValue("Authorization"); + 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(); + } + }); + } + + // Intercept all /_matrix/ networking requests for client and fail them + async goOffline(): Promise { + await this.setupPromise; + this.isOffline = true; + } + + // Remove intercept on all /_matrix/ networking requests for this client + async goOnline(): Promise { + await this.setupPromise; + this.isOffline = false; + } +} diff --git a/playwright/pages/settings.ts b/playwright/pages/settings.ts index 347886a0ab42..916ce26e0325 100644 --- a/playwright/pages/settings.ts +++ b/playwright/pages/settings.ts @@ -90,11 +90,11 @@ export class Settings { } /** - * Open room settings (via room menu), returns a locator to the dialog + * Open room settings (via room header menu), returns a locator to the dialog * @param tab the name of the tab to switch to after opening, optional. */ public async openRoomSettings(tab?: string): Promise { - await this.page.getByRole("main").getByRole("button", { name: "Room options", exact: true }).click(); + await this.page.getByRole("banner").getByRole("button", { name: "Room options", exact: true }).click(); await this.page.locator(".mx_RoomTile_contextMenu").getByRole("menuitem", { name: "Settings" }).click(); if (tab) await this.switchTab(tab); return this.page.locator(".mx_Dialog").filter({ has: this.page.locator(".mx_RoomSettingsDialog") }); diff --git a/playwright/pages/timeline.ts b/playwright/pages/timeline.ts new file mode 100644 index 000000000000..de9a9a58ec82 --- /dev/null +++ b/playwright/pages/timeline.ts @@ -0,0 +1,52 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Locator, Page } from "@playwright/test"; + +export class Timeline { + constructor(private page: Page) {} + + // Scroll to the top of the timeline + async scrollToTop(): Promise { + const locator = this.page.locator(".mx_RoomView_timeline .mx_ScrollPanel"); + await locator.evaluate((node) => { + while (node.scrollTop > 0) { + node.scrollTo(0, 0); + } + }); + } + + public async scrollToBottom(): Promise { + await this.page + .locator(".mx_ScrollPanel") + .evaluate((scrollPanel) => scrollPanel.scrollTo(0, scrollPanel.scrollHeight)); + } + + // Find the event tile matching the given sender & body + async findEventTile(sender: string, body: string): Promise { + const locators = await this.page.locator(".mx_RoomView_MessageList .mx_EventTile").all(); + let latestSender: string; + for (const locator of locators) { + const displayName = locator.locator(".mx_DisambiguatedProfile_displayName"); + if (await displayName.count()) { + latestSender = await displayName.innerText(); + } + if (latestSender === sender && (await locator.locator(".mx_EventTile_body").innerText()) === body) { + return locator; + } + } + } +} diff --git a/playwright/plugins/homeserver/dendrite/index.ts b/playwright/plugins/homeserver/dendrite/index.ts index 2080534041aa..2ca54cc0d8f0 100644 --- a/playwright/plugins/homeserver/dendrite/index.ts +++ b/playwright/plugins/homeserver/dendrite/index.ts @@ -74,14 +74,16 @@ export class Dendrite extends Synapse implements Homeserver, HomeserverInstance "http://localhost:8008/_matrix/client/versions", ]); + const dockerUrl = `http://${await this.docker.getContainerIp()}:8008`; this.config = { ...denCfg, serverId: dendriteId, + dockerUrl, }; return this; } - public async stop(): Promise { + public async stop(): Promise { if (!this.config) throw new Error("Missing existing dendrite instance, did you call stop() before start()?"); const dendriteLogsPath = path.join("playwright", "dendritelogs", this.config.serverId); @@ -97,6 +99,8 @@ export class Dendrite extends Synapse implements Homeserver, HomeserverInstance await fse.remove(this.config.configDir); console.log(`Stopped dendrite id ${this.config.serverId}.`); + + return [path.join(dendriteLogsPath, "stdout.log"), path.join(dendriteLogsPath, "stderr.log")]; } } @@ -105,7 +109,10 @@ export class Pinecone extends Dendrite { protected entrypoint = "/usr/bin/dendrite-demo-pinecone"; } -async function cfgDirFromTemplate(dendriteImage: string, opts: StartHomeserverOpts): Promise { +async function cfgDirFromTemplate( + dendriteImage: string, + opts: StartHomeserverOpts, +): Promise> { const template = "default"; // XXX: for now we only have one template const templateDir = path.join(__dirname, "templates", template); diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts index bd01f0e555e7..1e0cfb3b39c6 100644 --- a/playwright/plugins/homeserver/index.ts +++ b/playwright/plugins/homeserver/index.ts @@ -19,6 +19,7 @@ export interface HomeserverConfig { readonly baseUrl: string; readonly port: number; readonly registrationSecret: string; + readonly dockerUrl: string; } export interface HomeserverInstance { @@ -53,7 +54,12 @@ export interface StartHomeserverOpts { export interface Homeserver { start(opts: StartHomeserverOpts): Promise; - stop(): Promise; + /** + * Stop this test homeserver instance. + * + * @returns A list of paths relative to the cwd for logfiles generated during this test run. + */ + stop(): Promise; } export interface Credentials { diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index 78a37d3a1767..95165c144284 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -25,7 +25,7 @@ import { Docker } from "../../docker"; import { HomeserverConfig, HomeserverInstance, Homeserver, StartHomeserverOpts, Credentials } from ".."; import { randB64Bytes } from "../../utils/rand"; -async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise { +async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); const stats = await fse.stat(templateDir); @@ -84,6 +84,10 @@ async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise ${outputSigningKey}`); await fse.writeFile(outputSigningKey, `ed25519 x ${signingKey}`); + // Allow anyone to read, write and execute in the /temp/react-sdk-synapsedocker-xxx directory + // so that the DIND setup that we use to update the playwright screenshots work without any issues. + await fse.chmod(tempDir, 0o757); + return { port, baseUrl, @@ -142,15 +146,16 @@ export class Synapse implements Homeserver, HomeserverInstance { "--silent", "http://localhost:8008/health", ]); - + const dockerUrl = `http://${await this.docker.getContainerIp()}:8008`; this.config = { ...synCfg, serverId: synapseId, + dockerUrl, }; return this; } - public async stop(): Promise { + public async stop(): Promise { if (!this.config) throw new Error("Missing existing synapse instance, did you call stop() before start()?"); const id = this.config.serverId; const synapseLogsPath = path.join("playwright", "synapselogs", id); @@ -162,6 +167,8 @@ export class Synapse implements Homeserver, HomeserverInstance { await this.docker.stop(); await fse.remove(this.config.configDir); console.log(`Stopped synapse id ${id}.`); + + return [path.join(synapseLogsPath, "stdout.log"), path.join(synapseLogsPath, "stderr.log")]; } public async registerUser(username: string, password: string, displayName?: string): Promise { diff --git a/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/1.0.html b/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/1.0.html index 8ee888518ab8..bcc7a590bb2d 100644 --- a/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/1.0.html +++ b/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/1.0.html @@ -1,4 +1,4 @@ - + Test Privacy policy diff --git a/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/success.html b/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/success.html index 8db01e8a6e75..2a2b21eef4ee 100644 --- a/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/success.html +++ b/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/success.html @@ -1,4 +1,4 @@ - + Test Privacy policy diff --git a/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml index e51ac1918ffb..76cedb78f86e 100644 --- a/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml +++ b/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml @@ -89,6 +89,12 @@ oidc_providers: discover: false scopes: ["profile"] skip_verification: true + client_auth_method: none user_mapping_provider: config: display_name_template: "{{ user.name }}" + +# Inhibit background updates as this Synapse isn't long-lived +background_updates: + min_batch_size: 100000 + sleep_duration_ms: 100000 diff --git a/playwright/plugins/sliding-sync-proxy/index.ts b/playwright/plugins/sliding-sync-proxy/index.ts new file mode 100644 index 000000000000..b8cc365ffb6c --- /dev/null +++ b/playwright/plugins/sliding-sync-proxy/index.ts @@ -0,0 +1,99 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { getFreePort } from "../utils/port"; +import { Docker } from "../docker"; + +// Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image. +const SLIDING_SYNC_PROXY_TAG = "v0.99.3"; +const PG_PASSWORD = "p4S5w0rD"; + +export interface ProxyInstance { + containerId: string; + postgresId: string; + port: number; +} + +export class SlidingSyncProxy { + private readonly postgresDocker = new Docker(); + private readonly proxyDocker = new Docker(); + private instance: ProxyInstance; + + constructor(private synapseIp: string) {} + + private async waitForPostgresReady(): Promise { + const waitTimeMillis = 30000; + const startTime = new Date().getTime(); + let lastErr: Error | null = null; + while (new Date().getTime() - startTime < waitTimeMillis) { + try { + await this.postgresDocker.exec(["pg_isready", "-U", "postgres"]); + lastErr = null; + break; + } catch (err) { + console.log("pg_isready: failed"); + lastErr = err; + } + } + if (lastErr) { + console.log("rethrowing"); + throw lastErr; + } + } + + async start(): Promise { + console.log(new Date(), "Starting sliding sync proxy..."); + + const postgresId = await this.postgresDocker.run({ + image: "postgres", + containerName: "react-sdk-playwright-sliding-sync-postgres", + params: ["--rm", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`], + }); + + const postgresIp = await this.postgresDocker.getContainerIp(); + console.log(new Date(), "postgres container up"); + + await this.waitForPostgresReady(); + + const port = await getFreePort(); + console.log(new Date(), "starting proxy container...", SLIDING_SYNC_PROXY_TAG); + const containerId = await this.proxyDocker.run({ + image: "ghcr.io/matrix-org/sliding-sync:" + SLIDING_SYNC_PROXY_TAG, + containerName: "react-sdk-playwright-sliding-sync-proxy", + params: [ + "--rm", + "-p", + `${port}:8008/tcp`, + "-e", + "SYNCV3_SECRET=bwahahaha", + "-e", + `SYNCV3_SERVER=${this.synapseIp}`, + "-e", + `SYNCV3_DB=user=postgres dbname=postgres password=${PG_PASSWORD} host=${postgresIp} sslmode=disable`, + ], + }); + console.log(new Date(), "started!"); + + this.instance = { containerId, postgresId, port }; + return this.instance; + } + + async stop(): Promise { + await this.postgresDocker.stop(); + await this.proxyDocker.stop(); + console.log(new Date(), "Stopped sliding sync proxy."); + } +} diff --git a/playwright/plugins/webserver/index.ts b/playwright/plugins/webserver/index.ts index 2fe083f179b1..1bc2cbfa429f 100644 --- a/playwright/plugins/webserver/index.ts +++ b/playwright/plugins/webserver/index.ts @@ -33,7 +33,7 @@ export class Webserver { const address = this.server.address() as AddressInfo; console.log(`Started webserver at ${address.address}:${address.port}`); - return `http://localhost:${address.port}/`; + return `http://localhost:${address.port}`; } public stop(): void { diff --git a/cypress/fixtures/1sec-long-name-audio-file.ogg b/playwright/sample-files/1sec-long-name-audio-file.ogg similarity index 100% rename from cypress/fixtures/1sec-long-name-audio-file.ogg rename to playwright/sample-files/1sec-long-name-audio-file.ogg diff --git a/cypress/fixtures/1sec.ogg b/playwright/sample-files/1sec.ogg similarity index 100% rename from cypress/fixtures/1sec.ogg rename to playwright/sample-files/1sec.ogg diff --git a/playwright/sample-files/element.png b/playwright/sample-files/element.png new file mode 100644 index 000000000000..53ca7652b4eb Binary files /dev/null and b/playwright/sample-files/element.png differ diff --git a/cypress/fixtures/matrix-org-client-versions.json b/playwright/sample-files/matrix-org-client-versions.json similarity index 100% rename from cypress/fixtures/matrix-org-client-versions.json rename to playwright/sample-files/matrix-org-client-versions.json diff --git a/cypress/fixtures/upload-first.ogg b/playwright/sample-files/upload-first.ogg similarity index 100% rename from cypress/fixtures/upload-first.ogg rename to playwright/sample-files/upload-first.ogg diff --git a/cypress/fixtures/upload-second.ogg b/playwright/sample-files/upload-second.ogg similarity index 100% rename from cypress/fixtures/upload-second.ogg rename to playwright/sample-files/upload-second.ogg diff --git a/cypress/fixtures/upload-third.ogg b/playwright/sample-files/upload-third.ogg similarity index 100% rename from cypress/fixtures/upload-third.ogg rename to playwright/sample-files/upload-third.ogg diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png new file mode 100644 index 000000000000..1a6148c9c142 Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png new file mode 100644 index 000000000000..6d074f2773ce Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png new file mode 100644 index 000000000000..98ce66178e0c Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png new file mode 100644 index 000000000000..7e22dea5dfd0 Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png new file mode 100644 index 000000000000..655baf15a23f Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png new file mode 100644 index 000000000000..f230fe06da4a Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png new file mode 100644 index 000000000000..6968d3176dc7 Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png new file mode 100644 index 000000000000..2e661cac95c5 Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png new file mode 100644 index 000000000000..0c474b2c5cc2 Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png new file mode 100644 index 000000000000..1d8b5771d41f Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png new file mode 100644 index 000000000000..a59f8fbf17bf Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png new file mode 100644 index 000000000000..4b74eb574536 Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png new file mode 100644 index 000000000000..6a486d591f06 Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png new file mode 100644 index 000000000000..22b7b56d274e Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png new file mode 100644 index 000000000000..8592436e7553 Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png new file mode 100644 index 000000000000..5ec4e38282e1 Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png new file mode 100644 index 000000000000..00a570e9338e Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png new file mode 100644 index 000000000000..a3bbd871628d Binary files /dev/null and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png differ diff --git a/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png new file mode 100644 index 000000000000..e5dae2a5d323 Binary files /dev/null and b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png differ diff --git a/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png new file mode 100644 index 000000000000..f32be24fb49c Binary files /dev/null and b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png b/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png new file mode 100644 index 000000000000..a7194257898e Binary files /dev/null and b/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png new file mode 100644 index 000000000000..73deb5b9293d Binary files /dev/null and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png new file mode 100644 index 000000000000..d5348395c803 Binary files /dev/null and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png differ diff --git a/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png b/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png index 2c63adacd1c0..a01cabd6e4a9 100644 Binary files a/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png and b/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png b/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png index 55d820a066c4..c9c4346c8ed0 100644 Binary files a/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png and b/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/registration-linux.png b/playwright/snapshots/register/register.spec.ts/registration-linux.png index fed14e2c8a33..9540d32692ce 100644 Binary files a/playwright/snapshots/register/register.spec.ts/registration-linux.png and b/playwright/snapshots/register/register.spec.ts/registration-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png b/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png index 9efa65959210..96c67985c990 100644 Binary files a/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png and b/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png b/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png index c7ed8fc86431..6617e64aadcf 100644 Binary files a/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png and b/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png index 69c021429c2d..02e65b2379aa 100644 Binary files a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png and b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png index 0ad317d37d9a..7cb26aa5bae1 100644 Binary files a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png and b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png new file mode 100644 index 000000000000..3b9781553d03 Binary files /dev/null and b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png new file mode 100644 index 000000000000..db736e2fe513 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png new file mode 100644 index 000000000000..d4f2492a1a2f Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png new file mode 100644 index 000000000000..a954d9a007da Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png new file mode 100644 index 000000000000..dfe188636c1b Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-bubble-layout-linux.png new file mode 100644 index 000000000000..3a0da67d18ec Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-compact-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-compact-modern-layout-linux.png new file mode 100644 index 000000000000..cbf9c2927a8f Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-compact-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-layout-linux.png new file mode 100644 index 000000000000..b7ec69155240 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-modern-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-modern-linux.png new file mode 100644 index 000000000000..ed65bbc63d6c Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-modern-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png new file mode 100644 index 000000000000..3d684c73cb28 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png new file mode 100644 index 000000000000..dc5484c77c76 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png new file mode 100644 index 000000000000..a5e26c3e6909 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png new file mode 100644 index 000000000000..a8587e76046e Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png new file mode 100644 index 000000000000..c16ff4480adf Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png new file mode 100644 index 000000000000..f8b0504ec98c Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png new file mode 100644 index 000000000000..4d4972d9f3cd Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png new file mode 100644 index 000000000000..dfe188636c1b Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png new file mode 100644 index 000000000000..90db42b411eb Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png new file mode 100644 index 000000000000..791a1f93a202 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png new file mode 100644 index 000000000000..b4ab81ebe63e Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png new file mode 100644 index 000000000000..8c93664911cd Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png new file mode 100644 index 000000000000..ff75b3473fea Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png new file mode 100644 index 000000000000..100dc86c7a5c Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png new file mode 100644 index 000000000000..8d6e0898348b Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png new file mode 100644 index 000000000000..a7c7ecc5b86f Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png new file mode 100644 index 000000000000..f51b8eb33243 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-bar-on-timeline-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-bar-on-timeline-linux.png new file mode 100644 index 000000000000..64d44a9778bf Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/search-bar-on-timeline-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png new file mode 100644 index 000000000000..4c553cfdafbe Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/url-preview-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/url-preview-linux.png new file mode 100644 index 000000000000..49f2c0bad832 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/url-preview-linux.png differ diff --git a/playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png b/playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png new file mode 100644 index 000000000000..20618f5d66e3 Binary files /dev/null and b/playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png differ diff --git a/playwright/tsconfig.json b/playwright/tsconfig.json index cea25ebab7dd..aea1bc543ae6 100644 --- a/playwright/tsconfig.json +++ b/playwright/tsconfig.json @@ -6,11 +6,11 @@ "resolveJsonModule": true, "esModuleInterop": true, "moduleResolution": "node", - "module": "es2022" + "module": "es2022", }, "include": [ "**/*.ts", "../node_modules/matrix-js-sdk/src/@types/*.d.ts", - "../node_modules/matrix-js-sdk/node_modules/@matrix-org/olm/index.d.ts" - ] + "../node_modules/matrix-js-sdk/node_modules/@matrix-org/olm/index.d.ts", + ], } diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 020767b0ec4c..7979a04e3470 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -52,6 +52,18 @@ limitations under the License. --dialog-zIndex-standard: calc(var(--dialog-zIndex-standard-background) + 1); /* 4012 */ } +#matrixchat { + /* This is required to ensure Compound tooltips correctly draw where they should with z-index: auto */ + contain: strict; +} +#mx_ContextualMenu_Container, +#mx_PersistedElement_container, +#mx_Dialog_Container, +#mx_Dialog_StaticContainer { + /* This is required to ensure Compound tooltips correctly draw where they should with z-index: auto */ + isolation: isolate; +} + /** * We need to increase the specificity of the selector to override the * custom property set by the design tokens package @@ -92,7 +104,10 @@ body { * `font-feature-settings` allows us to override this behaviour and have the * correct ligatures and the proper dynamic metric spacing. */ - font-feature-settings: "kern" 1, "liga" 1, "calt" 1; + font-feature-settings: + "kern" 1, + "liga" 1, + "calt" 1; background-color: $background; color: $primary-content; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b7bfde21d885..17ab3c0bb22a 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -92,6 +92,7 @@ @import "./structures/auth/_CompleteSecurity.pcss"; @import "./structures/auth/_ConfirmSessionLockTheftView.pcss"; @import "./structures/auth/_Login.pcss"; +@import "./structures/auth/_LoginSplashView.pcss"; @import "./structures/auth/_Registration.pcss"; @import "./structures/auth/_SessionLockStolenView.pcss"; @import "./structures/auth/_SetupEncryptionBody.pcss"; diff --git a/res/css/components/views/dialogs/polls/_PollDetailHeader.pcss b/res/css/components/views/dialogs/polls/_PollDetailHeader.pcss index 6f29b6e08fd3..af3a9c2a7009 100644 --- a/res/css/components/views/dialogs/polls/_PollDetailHeader.pcss +++ b/res/css/components/views/dialogs/polls/_PollDetailHeader.pcss @@ -15,7 +15,7 @@ limitations under the License. */ .mx_PollDetailHeader { - // override accessiblebutton style + /* override accessiblebutton style */ font-size: $font-15px !important; } diff --git a/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss b/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss index 16ea5dcce07c..51b2a07d9b15 100644 --- a/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss +++ b/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss @@ -60,6 +60,6 @@ limitations under the License. } .mx_PollListItemEnded_voteCount { - // 6px to match PollOption padding + /* 6px to match PollOption padding */ margin: $spacing-8 0 0 6px; } diff --git a/res/css/components/views/elements/_FilterTabGroup.pcss b/res/css/components/views/elements/_FilterTabGroup.pcss index 946bd7f54311..5f7338f81e36 100644 --- a/res/css/components/views/elements/_FilterTabGroup.pcss +++ b/res/css/components/views/elements/_FilterTabGroup.pcss @@ -39,7 +39,7 @@ limitations under the License. &:checked + span { color: $accent; font-weight: var(--cpd-font-weight-semibold); - // underline + /* underline */ box-shadow: 0 1.5px 0 0 currentColor; } } diff --git a/res/css/components/views/pips/_WidgetPip.pcss b/res/css/components/views/pips/_WidgetPip.pcss index f6bf5a2a63ea..bc0419a49379 100644 --- a/res/css/components/views/pips/_WidgetPip.pcss +++ b/res/css/components/views/pips/_WidgetPip.pcss @@ -14,11 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ +$width: 320px; +$height: 220px; + .mx_WidgetPip { - width: 320px; - height: 220px; + width: $width; + height: $height; +} + +.mx_WidgetPip_overlay { + width: $width; + height: $height; + position: absolute; + top: 0; border-radius: 8px; - contain: paint; + overflow: hidden; color: $call-primary-content; cursor: pointer; } @@ -31,8 +41,11 @@ limitations under the License. width: 100%; box-sizing: border-box; transition: opacity ease 0.15s; +} - .mx_WidgetPip:not(:hover) > & { +.mx_WidgetPip_overlay:not(:hover) { + .mx_WidgetPip_header, + .mx_WidgetPip_footer { opacity: 0; } } diff --git a/res/css/components/views/polls/_PollOption.pcss b/res/css/components/views/polls/_PollOption.pcss index da4c66d6cf1d..f8766de6a4dc 100644 --- a/res/css/components/views/polls/_PollOption.pcss +++ b/res/css/components/views/polls/_PollOption.pcss @@ -66,8 +66,7 @@ limitations under the License. } } - // override checked radio button styling - // to show checkmark instead + /* override checked radio button styling to show checkmark instead */ .mx_StyledRadioButton_checked { input[type="radio"] + div { border-width: 2px; diff --git a/res/css/components/views/settings/devices/_CurrentDeviceSection.pcss b/res/css/components/views/settings/devices/_CurrentDeviceSection.pcss index 552270db1d02..d91dd64575cd 100644 --- a/res/css/components/views/settings/devices/_CurrentDeviceSection.pcss +++ b/res/css/components/views/settings/devices/_CurrentDeviceSection.pcss @@ -15,6 +15,6 @@ limitations under the License. */ .mx_CurrentDeviceSection_deviceDetails { - // align with text of session tile + /* align with text of session tile */ margin-left: 56px; } diff --git a/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss b/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss index b62cc531893f..841102536fe6 100644 --- a/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss +++ b/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss @@ -44,7 +44,7 @@ limitations under the License. } .mx_DeviceDetailHeading_renameFormInput { - // override field styles + /* override field styles */ margin: 0 0 $spacing-4 0 !important; } diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss index 9f9bd0cc712d..857e56e34c80 100644 --- a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss @@ -45,13 +45,13 @@ limitations under the License. .mx_FilteredDeviceList_headerButton { flex-shrink: 0; - // override inline button styling + /* override inline button styling */ display: flex !important; flex-direction: row; gap: $spacing-8; } .mx_FilteredDeviceList_deviceDetails { - // align with text of session tile + /* align with text of session tile */ margin-left: 88px; } diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index 6ee9ac429c89..44d0a3442642 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -33,8 +33,7 @@ limitations under the License. width: 100%; display: grid; grid-gap: $spacing-8; - // setting minwidth 0 makes columns definitely sized - // fixing horizontal overflow + /* setting minwidth 0 makes columns definitely sized fixing horizontal overflow */ grid-template-columns: minmax(0, 1fr); justify-items: flex-start; margin-top: $spacing-24; diff --git a/res/css/structures/_AutocompleteInput.pcss b/res/css/structures/_AutocompleteInput.pcss index 754c8ae19448..5e799c95a7e4 100644 --- a/res/css/structures/_AutocompleteInput.pcss +++ b/res/css/structures/_AutocompleteInput.pcss @@ -38,7 +38,7 @@ limitations under the License. flex: 1; min-width: 40%; resize: none; - // `!important` is required to bypass global input styles. + /* `!important` is required to bypass global input styles. */ margin: 0 !important; padding: $spacing-8 9px; border: none !important; diff --git a/res/css/structures/_MatrixChat.pcss b/res/css/structures/_MatrixChat.pcss index 9aef8293e270..cdbe2fcfefcb 100644 --- a/res/css/structures/_MatrixChat.pcss +++ b/res/css/structures/_MatrixChat.pcss @@ -19,13 +19,6 @@ limitations under the License. height: 100%; } -.mx_MatrixChat_splashButtons { - text-align: center; - width: 100%; - position: absolute; - bottom: 30px; -} - .mx_MatrixChat_wrapper { display: flex; @@ -50,18 +43,6 @@ limitations under the License. min-height: 0; } -.mx_MatrixChat_syncError { - color: $accent-fg-color; - background-color: #df2a8b; /* Only used here */ - border-radius: 5px; - display: table; - padding: 30px; - position: absolute; - top: 100px; - left: 50%; - transform: translateX(-50%); -} - /* not the left panel, and not the resize handle, so the roomview and friends */ .mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_SpacePanel):not(.mx_ResizeHandle):not(.mx_LeftPanel_outerWrapper) { background-color: $background; diff --git a/res/css/structures/_MessagePanel.pcss b/res/css/structures/_MessagePanel.pcss index c5e777b3e6d8..487f6dd801ff 100644 --- a/res/css/structures/_MessagePanel.pcss +++ b/res/css/structures/_MessagePanel.pcss @@ -28,7 +28,9 @@ limitations under the License. top: -1px; z-index: 1; will-change: width; - transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s; + transition: + width 400ms easeinsine 1s, + opacity 400ms easeinsine 1s; width: 99%; opacity: 1; } diff --git a/res/css/structures/_QuickSettingsButton.pcss b/res/css/structures/_QuickSettingsButton.pcss index 631b098ad645..f5dc45356fa6 100644 --- a/res/css/structures/_QuickSettingsButton.pcss +++ b/res/css/structures/_QuickSettingsButton.pcss @@ -36,10 +36,10 @@ limitations under the License. width: 32px; height: 32px; left: 0; - mask-image: url("$(res)/img/element-icons/settings.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg"); mask-repeat: no-repeat; mask-position: center; - mask-size: 16px; + mask-size: 24px; background: $secondary-content; } diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 02f6f50363dd..d23f6f8c69f3 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -266,7 +266,7 @@ limitations under the License. flex-grow: 1; .mx_SpaceTreeLevel { - // Indent subspaces + /* Indent subspaces */ padding-left: 16px; } } @@ -416,7 +416,6 @@ limitations under the License. font-size: $font-10px; line-height: $font-12px; font-weight: var(--cpd-font-weight-semibold); - //margin-left: 8px; } } diff --git a/res/css/structures/_SplashPage.pcss b/res/css/structures/_SplashPage.pcss index be87b04c78d7..58c462a226c1 100644 --- a/res/css/structures/_SplashPage.pcss +++ b/res/css/structures/_SplashPage.pcss @@ -36,7 +36,8 @@ limitations under the License. filter: blur(8px); inset: -9px; mask: - /* mask to dither resulting combined gradient */ url("$(res)/img/noise.png"), + /* mask to dither resulting combined gradient */ + url("$(res)/img/noise.png"), /* gradient to apply different amounts of dithering to different parts of the gradient */ linear-gradient( to bottom, diff --git a/res/css/structures/auth/_LoginSplashView.pcss b/res/css/structures/auth/_LoginSplashView.pcss new file mode 100644 index 000000000000..fc9433782d9a --- /dev/null +++ b/res/css/structures/auth/_LoginSplashView.pcss @@ -0,0 +1,51 @@ +/* +Copyright 2015-2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_LoginSplashView_migrationProgress { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + + .mx_ProgressBar { + height: 8px; + width: 600px; + + @mixin ProgressBarBorderRadius 8px; + } +} + +.mx_LoginSplashView_splashButtons { + text-align: center; + width: 100%; + position: absolute; + bottom: 30px; +} + +.mx_LoginSplashView_syncError { + color: $accent-fg-color; + background-color: #df2a8b; /* Only used here */ + border-radius: 5px; + display: table; + padding: 30px; + position: absolute; + top: 100px; + left: 50%; + transform: translateX(-50%); +} diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index 5bce1bbfeaa1..770438e9729b 100644 --- a/res/css/views/auth/_AuthBody.pcss +++ b/res/css/views/auth/_AuthBody.pcss @@ -67,7 +67,7 @@ limitations under the License. .mx_AuthBody_lockIcon { color: $secondary-content; height: 32px; - margin-bottom: -3px; // tweak to align all icons on different forgot password steps + margin-bottom: -3px; /* tweak to align all icons on different forgot password steps */ } .mx_AuthBody_text { @@ -211,9 +211,9 @@ limitations under the License. } .mx_AuthBody_emailPromptIcon--shifted { - margin-bottom: -17px; // Prevent layout jump by relative positioning. + margin-bottom: -17px; /* Prevent layout jump by relative positioning. */ position: relative; - top: -17px; // This icon is higher than the other icons. Shift up to prevent icon jumping. + top: -17px; /* This icon is higher than the other icons. Shift up to prevent icon jumping. */ width: 57px; } diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.pcss b/res/css/views/avatars/_DecoratedRoomAvatar.pcss index 2d430981a359..e5ff87b11dd6 100644 --- a/res/css/views/avatars/_DecoratedRoomAvatar.pcss +++ b/res/css/views/avatars/_DecoratedRoomAvatar.pcss @@ -30,9 +30,9 @@ limitations under the License. .mx_DecoratedRoomAvatar_icon { position: absolute; /* the following percentage based sizings are to match the scalable svg mask for the cutout */ - bottom: 6.25%; // 2px for a 32x32 avatar + bottom: 6.25%; /* 2px for a 32x32 avatar */ right: 6.25%; - width: 25%; // 8px for a 32x32 avatar + width: 25%; /* 8px for a 32x32 avatar */ height: 25%; border-radius: 50%; } diff --git a/res/css/views/context_menus/_IconizedContextMenu.pcss b/res/css/views/context_menus/_IconizedContextMenu.pcss index eaad0adbb850..f36c807b6061 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.pcss +++ b/res/css/views/context_menus/_IconizedContextMenu.pcss @@ -18,7 +18,7 @@ limitations under the License. .mx_IconizedContextMenu { min-width: 146px; width: max-content; - // override default ul styles + /* override default ul styles */ margin: 0; padding: 0; diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss b/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss index 3a0a95811c57..87372e6c47e7 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss @@ -38,7 +38,9 @@ limitations under the License. } .mx_AddExistingToSpace_section { - margin-right: 12px; // provides space for scrollbar so that checkbox and scrollbar do not collide + margin-right: 12px; + + // provides space for scrollbar so that checkbox and scrollbar do not collide &:not(:first-child) { margin-top: 24px; diff --git a/res/css/views/dialogs/_FeedbackDialog.pcss b/res/css/views/dialogs/_FeedbackDialog.pcss index 06c18ceddbec..2efc6bc047c8 100644 --- a/res/css/views/dialogs/_FeedbackDialog.pcss +++ b/res/css/views/dialogs/_FeedbackDialog.pcss @@ -97,7 +97,9 @@ limitations under the License. .mx_StyledRadioButton { display: inline-flex; font-size: 20px; - transition: font-size 1s, border 0.5s; + transition: + font-size 1s, + border 0.5s; border-radius: 50%; border: 2px solid transparent; margin-top: 12px; diff --git a/res/css/views/elements/_Dropdown.pcss b/res/css/views/elements/_Dropdown.pcss index 8ab577a4cf17..28e96f0146f9 100644 --- a/res/css/views/elements/_Dropdown.pcss +++ b/res/css/views/elements/_Dropdown.pcss @@ -58,7 +58,7 @@ limitations under the License. .mx_Dropdown_option { height: 35px; line-height: $font-35px; - // Overwrites the default padding for any li elements + /* Overwrites the default padding for any li elements */ padding: 0 8px; } diff --git a/res/css/views/elements/_Field.pcss b/res/css/views/elements/_Field.pcss index 52ebb0f89554..02b0e482b55b 100644 --- a/res/css/views/elements/_Field.pcss +++ b/res/css/views/elements/_Field.pcss @@ -95,7 +95,10 @@ limitations under the License. } .mx_Field label { - transition: font-size 0.25s ease-out 0.1s, color 0.25s ease-out 0.1s, transform 0.25s ease-out 0.1s, + transition: + font-size 0.25s ease-out 0.1s, + color 0.25s ease-out 0.1s, + transform 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s; background-color: transparent; font: var(--cpd-font-body-md-regular); @@ -117,7 +120,10 @@ limitations under the License. .mx_Field input:not(:placeholder-shown) + label, .mx_Field textarea:focus + label, .mx_Field textarea:not(:placeholder-shown) + label { - transition: font-size 0.25s ease-out 0s, color 0.25s ease-out 0s, transform 0.25s ease-out 0s, + transition: + font-size 0.25s ease-out 0s, + color 0.25s ease-out 0s, + transform 0.25s ease-out 0s, background-color 0.25s ease-out 0s; font-size: $font-10px; transform: translateY(-13px); diff --git a/res/css/views/elements/_GenericEventListSummary.pcss b/res/css/views/elements/_GenericEventListSummary.pcss index f05a15b44d20..679078659c96 100644 --- a/res/css/views/elements/_GenericEventListSummary.pcss +++ b/res/css/views/elements/_GenericEventListSummary.pcss @@ -32,7 +32,7 @@ limitations under the License. } .mx_GenericEventListSummary_toggle { - // We reuse a title cased translation + /* We reuse a title cased translation */ text-transform: lowercase; } diff --git a/res/css/views/elements/_InteractiveTooltip.pcss b/res/css/views/elements/_InteractiveTooltip.pcss index 553e9edf9892..1711b31aa074 100644 --- a/res/css/views/elements/_InteractiveTooltip.pcss +++ b/res/css/views/elements/_InteractiveTooltip.pcss @@ -25,7 +25,9 @@ limitations under the License. color: $primary-content; position: absolute; z-index: 5001; - box-shadow: 0 24px 8px rgb(17 17 26 / 4%), 0 8px 32px rgb(17 17 26 / 4%); + box-shadow: + 0 24px 8px rgb(17 17 26 / 4%), + 0 8px 32px rgb(17 17 26 / 4%); } .mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_top { diff --git a/res/css/views/elements/_ReplyChain.pcss b/res/css/views/elements/_ReplyChain.pcss index 8e054e7d6cdb..e142ab128ca9 100644 --- a/res/css/views/elements/_ReplyChain.pcss +++ b/res/css/views/elements/_ReplyChain.pcss @@ -15,7 +15,7 @@ limitations under the License. */ .mx_ReplyChain { - margin: 0; // Reset default blockquote margin + margin: 0; /* Reset default blockquote margin */ padding-left: 10px; border-left: 2px solid var(--username-color); border-radius: 2px; diff --git a/res/css/views/elements/_Slider.pcss b/res/css/views/elements/_Slider.pcss index 7fd3520c8b18..58d448491933 100644 --- a/res/css/views/elements/_Slider.pcss +++ b/res/css/views/elements/_Slider.pcss @@ -24,7 +24,7 @@ limitations under the License. appearance: none; width: 100%; background: none; - font-size: 1em; // set base multiplier for em units applied later + font-size: 1em; /* set base multiplier for em units applied later */ --active-color: $accent; --selection-dot-size: 2.4em; @@ -114,7 +114,7 @@ limitations under the License. left: 50%; transform: translateX(-50%); - font-size: 1em; // set base multiplier for em units applied later + font-size: 1em; /* set base multiplier for em units applied later */ text-align: center; top: 3em; diff --git a/res/css/views/messages/_MStickerBody.pcss b/res/css/views/messages/_MStickerBody.pcss index 4d1b49b5ff07..5fea4adf86dd 100644 --- a/res/css/views/messages/_MStickerBody.pcss +++ b/res/css/views/messages/_MStickerBody.pcss @@ -18,11 +18,6 @@ limitations under the License. padding: 12px 0px; } -.mx_MStickerBody_tooltip { - position: absolute; - top: 50%; -} - .mx_MStickerBody_hidden { max-width: 220px; text-decoration: none; diff --git a/res/css/views/messages/_RoomAvatarEvent.pcss b/res/css/views/messages/_RoomAvatarEvent.pcss index 844c36544903..6b232cd930a2 100644 --- a/res/css/views/messages/_RoomAvatarEvent.pcss +++ b/res/css/views/messages/_RoomAvatarEvent.pcss @@ -17,5 +17,5 @@ limitations under the License. .mx_RoomAvatarEvent_avatar { display: inline; position: relative; - top: 5px; + top: 3px; } diff --git a/res/css/views/right_panel/_BaseCard.pcss b/res/css/views/right_panel/_BaseCard.pcss index 79d18a7dbbae..ae5b1ac2c51a 100644 --- a/res/css/views/right_panel/_BaseCard.pcss +++ b/res/css/views/right_panel/_BaseCard.pcss @@ -158,9 +158,9 @@ limitations under the License. .mx_BaseCard_close { flex-shrink: 0; position: relative; - // @TODO(kerrya) background colours here are not semantic - // these buttons to be replaced with IconButton after secondary variant is added - // https://github.com/vector-im/compound/issues/279 + /* @TODO(kerrya) background colours here are not semantic + these buttons to be replaced with IconButton after secondary variant is added + https://github.com/vector-im/compound/issues/279 */ background-color: var(--cpd-color-bg-subtle-secondary); width: var(--BaseCard_header-button-size); height: var(--BaseCard_header-button-size); diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 13e64cb90370..1dfd39dd250d 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -133,7 +133,7 @@ limitations under the License. line-height: $font-25px; flex: 1; justify-content: center; - // We reverse things here so for accessible technologies the name comes before the e2e shield + /* We reverse things here so for accessible technologies the name comes before the e2e shield */ flex-direction: row-reverse; span { diff --git a/res/css/views/rooms/_AppsDrawer.pcss b/res/css/views/rooms/_AppsDrawer.pcss index eeb533c0af6a..fc595683226c 100644 --- a/res/css/views/rooms/_AppsDrawer.pcss +++ b/res/css/views/rooms/_AppsDrawer.pcss @@ -321,6 +321,13 @@ limitations under the License. display: none; } } + + &.mx_AppTileBody--call { + border-radius: 0px; + } + &.mx_AppTileBody--call.mx_AppTileBody--mini { + border-radius: 8px; + } } /* appTileBody is embedded to PersistedElement outside of mx_AppTile, diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index ad94b3c58e3e..6abf1ac837e5 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -355,12 +355,12 @@ limitations under the License. .mx_EventTile_e2eIcon { flex-shrink: 0; /* keep it at full size */ - // Keep height equal to text for shield alignment, additional 2px because of 1px padding on text + /* Keep height equal to text for shield alignment, additional 2px because of 1px padding on text */ height: calc($font-18px + 2px); } .mx_MPollEndBody { - // Prevent the poll end body from exceeding the tile width + /* Prevent the poll end body from exceeding the tile width */ width: 100%; } } diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 198e8d5e5182..31a1dc5dcb2d 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -488,7 +488,7 @@ $left-gutter: 64px; .mx_EventTile_e2eIcon { inset: 0 0 0 44px; - // Keep height equal to text for alignment + /* Keep height equal to text for alignment */ height: var(--EventTile_group_line-line-height); margin: 1px; } @@ -701,11 +701,11 @@ $left-gutter: 64px; background-color: $inlinecode-background-color; border: 1px solid $inlinecode-border-color; border-radius: 4px; - // The horizontal padding is added by gfm.css .markdown-body + /* The horizontal padding is added by gfm.css .markdown-body */ padding: $spacing-2 0; - // Avoid inline code blocks to be sticked when on multiple lines + /* Avoid inline code blocks to be sticked when on multiple lines */ line-height: $font-22px; - // Avoid the border to be glued to the other words + /* Avoid the border to be glued to the other words */ margin-right: $spacing-2; } @@ -792,7 +792,7 @@ $left-gutter: 64px; .mx_EventTile_spoiler { cursor: pointer; - // clear button styles + /* clear button styles */ appearance: none; background: none; border: none; @@ -852,17 +852,17 @@ $left-gutter: 64px; } &.mx_EventTile_e2eIcon_warning::after { - mask-image: url("$(res)/img/e2e/warning.svg"); // (!) in a shield - background-color: $e2e-warning-color; // red + mask-image: url("$(res)/img/e2e/warning.svg"); /* (!) in a shield */ + background-color: $e2e-warning-color; /* red */ } &.mx_EventTile_e2eIcon_normal::after { - mask-image: url("$(res)/img/e2e/normal.svg"); // regular shield - background-color: $header-panel-text-primary-color; // grey + mask-image: url("$(res)/img/e2e/normal.svg"); /* regular shield */ + background-color: $header-panel-text-primary-color; /* grey */ } &.mx_EventTile_e2eIcon_decryption_failure::after { - mask-image: url("$(res)/img/e2e/decryption-failure.svg"); // key in a circle + mask-image: url("$(res)/img/e2e/decryption-failure.svg"); /* key in a circle */ background-color: $secondary-content; } } @@ -1053,23 +1053,6 @@ $left-gutter: 64px; pointer-events: none; /* ensures the title for the sender name can be correctly displayed */ } - /* Display notification dot */ - &[data-notification]::before, - .mx_NotificationBadge { - position: absolute; - $notification-inset-block-start: 14px; /* 14px: align the dot with the timestamp row */ - - /* !important to fix overly specific CSS selector applied on mx_NotificationBadge */ - width: $notification-dot-size !important; - height: $notification-dot-size !important; - border-radius: 50%; - inset: $notification-inset-block-start $spacing-8 auto auto; - } - - .mx_NotificationBadge_count { - display: none; - } - &[data-notification="total"]::before { background-color: $room-icon-unread-color; } @@ -1441,14 +1424,6 @@ $left-gutter: 64px; margin-bottom: $spacing-4; /* 1/4 of the non-compact margin-bottom */ } } - - &[data-shape="ThreadsList"][data-notification]::before, - .mx_NotificationBadge { - /* stylelint-disable-next-line declaration-colon-space-after */ - inset-block-start: calc( - $notification-inset-block-start - var(--MatrixChat_useCompactLayout_group-padding-top) - ); - } } } diff --git a/res/css/views/rooms/_LegacyRoomHeader.pcss b/res/css/views/rooms/_LegacyRoomHeader.pcss index d9d2f4b22d18..9994f4223ea3 100644 --- a/res/css/views/rooms/_LegacyRoomHeader.pcss +++ b/res/css/views/rooms/_LegacyRoomHeader.pcss @@ -212,17 +212,17 @@ limitations under the License. top: var(--RoomHeader-indicator-dot-offset); margin: 4px; - &.mx_Indicator_red { + &.mx_Indicator_highlight { background: $alert; box-shadow: $alert; } - &.mx_Indicator_gray { + &.mx_Indicator_notification { background: $room-icon-unread-color; box-shadow: $room-icon-unread-color; } - &.mx_Indicator_bold { + &.mx_Indicator_activity { background: $primary-content; box-shadow: $primary-content; } diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss index 56a7da7ec660..b6e97fac391f 100644 --- a/res/css/views/rooms/_MessageComposer.pcss +++ b/res/css/views/rooms/_MessageComposer.pcss @@ -79,7 +79,9 @@ limitations under the License. .mx_MessageComposer_composecontrols { width: 100%; } - +.mx_MessageComposer_e2eIconWrapper { + height: 12px; /* Match the height of the E2E icon for alignment */ +} .mx_MessageComposer_e2eIcon.mx_E2EIcon { position: absolute; left: 20px; diff --git a/res/css/views/rooms/_NotificationBadge.pcss b/res/css/views/rooms/_NotificationBadge.pcss index 85895b097e35..41b1e0f530c7 100644 --- a/res/css/views/rooms/_NotificationBadge.pcss +++ b/res/css/views/rooms/_NotificationBadge.pcss @@ -33,19 +33,34 @@ limitations under the License. align-items: center; justify-content: center; - &.mx_NotificationBadge_highlighted { - /* TODO: Use a more specific variable */ - background-color: $alert; - } - /* These are the 3 background types */ &.mx_NotificationBadge_dot { - background-color: $primary-content; /* increased visibility */ + width: 8px; + height: 8px; + border-radius: 8px; + background-color: var(--cpd-color-text-primary); + + .mx_NotificationBadge_count { + display: none; + } + + /* Redundant sounding name, but a notification badge that indicates there is a regular, + * non-highlight notification + * The green colour only applies for notification dot: badges indicating the same notification + * level are the standard grey. + */ + &.mx_NotificationBadge_level_notification { + background-color: var(--cpd-color-icon-success-primary); + } + } - width: 6px; - height: 6px; - border-radius: 6px; + /* Badges for highlight notifications. Style for notification level + * badges is in _EventTile.scss because it applies only to notification + * dots, not badges. + */ + &.mx_NotificationBadge_level_highlight { + background-color: var(--cpd-color-icon-critical-primary); } &.mx_NotificationBadge_knocked { @@ -75,10 +90,3 @@ limitations under the License. } } } - -.mx_NotificationBadge_tooltip { - display: inline-block; - position: relative; - top: -25px; - left: 6px; -} diff --git a/res/css/views/rooms/_ReadReceiptGroup.pcss b/res/css/views/rooms/_ReadReceiptGroup.pcss index 7970d1f9aa67..5176a3208fbd 100644 --- a/res/css/views/rooms/_ReadReceiptGroup.pcss +++ b/res/css/views/rooms/_ReadReceiptGroup.pcss @@ -55,7 +55,9 @@ limitations under the License. width: 14px; height: 14px; will-change: left, top; - transition: left var(--transition-short) ease-out, top var(--transition-standard) ease-out; + transition: + left var(--transition-short) ease-out, + top var(--transition-standard) ease-out; } } } @@ -109,7 +111,7 @@ limitations under the License. margin: 6px 8px; align-self: center; justify-self: center; - flex-shrink: 0; // Long names should not shrink the picture + flex-shrink: 0; /* Long names should not shrink the picture */ } .mx_ReadReceiptGroup_name { diff --git a/res/css/views/rooms/_ReplyTile.pcss b/res/css/views/rooms/_ReplyTile.pcss index 1ffee9703f64..980e0b00d33d 100644 --- a/res/css/views/rooms/_ReplyTile.pcss +++ b/res/css/views/rooms/_ReplyTile.pcss @@ -37,7 +37,9 @@ limitations under the License. color: $secondary-content; transition: color ease 0.15s; gap: 2px; - max-width: 100%; // avoid overflow with wide content + max-width: 100%; + + // avoid overflow with wide content &:hover { color: $primary-content; @@ -112,7 +114,7 @@ limitations under the License. grid-template: "sender message" auto / max-content auto; - gap: 4px; // increase spacing + gap: 4px; /* increase spacing */ } .mx_ReplyTile_sender { @@ -124,31 +126,18 @@ limitations under the License. .mx_DisambiguatedProfile { font: var(--cpd-font-body-md-regular); - display: inline-block; // anti-zalgo, with overflow hidden + display: inline-block; /* anti-zalgo, with overflow hidden */ padding: 0; margin: 0; - // truncate long display names + /* truncate long display names */ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .mx_BaseAvatar { - line-height: 14px; // To match size - } - } -} - -@media only Percy { - /* Remove the list style in percy tests for screenshot consistency */ - :is(ul, ol) { - padding: 0 !important; - margin: 0 !important; - list-style: none !important; - - .mx_EventTile_last { - padding: 0 !important; + line-height: 14px; /* To match size */ } } } diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index a93366f520fa..bc66cd214179 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -28,7 +28,7 @@ limitations under the License. } .mx_RoomHeader_infoWrapper { - // unset button styles + /* unset button styles */ background: unset; border: unset; flex: 1; diff --git a/res/css/views/rooms/_ThreadSummary.pcss b/res/css/views/rooms/_ThreadSummary.pcss index 78a9d9e52922..ccfc527974c2 100644 --- a/res/css/views/rooms/_ThreadSummary.pcss +++ b/res/css/views/rooms/_ThreadSummary.pcss @@ -83,11 +83,6 @@ limitations under the License. } } - &::before { - @mixin ThreadSummaryIcon; - align-self: center; /* v-align the threads icon */ - } - .mx_ThreadSummary_sender, .mx_ThreadSummary_content, .mx_ThreadSummary_replies_amount { diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss index c4591c4c073c..d4194052fecd 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -46,8 +46,8 @@ limitations under the License. p { margin-top: 0; margin-bottom: 0; - // this may seem redundant, but we need to handle zero content formatting tags, which occur when we split a - // formatting tag into paragraphs + /* this may seem redundant, but we need to handle zero content formatting tags, which occur when we split a + formatting tag into paragraphs */ min-height: $font-22px; } diff --git a/res/css/views/settings/_Notifications.pcss b/res/css/views/settings/_Notifications.pcss index 2a1c450545b5..cc6ce5699b21 100644 --- a/res/css/views/settings/_Notifications.pcss +++ b/res/css/views/settings/_Notifications.pcss @@ -61,11 +61,11 @@ limitations under the License. font: var(--cpd-font-body-sm-semibold); } .mx_UserNotifSettings_gridRowError { - // occupy full row + /* occupy full row */ grid-column: 1/-1; justify-self: start; padding-right: 30%; - // collapse half of the grid-gap + /* collapse half of the grid-gap */ margin-top: -$spacing-4; } diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.pcss index e27751ebe1ac..ff4e2f17beb0 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.pcss @@ -16,7 +16,6 @@ limitations under the License. .mx_Field.mx_AppearanceUserSettingsTab_checkboxControlledField { width: 256px; - // matches checkbox box + padding - // to align with checkbox label + /* matches checkbox box + padding to align with checkbox label */ margin-inline-start: calc($font-16px + 10px); } diff --git a/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.pcss index cb90c0c15fa4..26e042bffbc8 100644 --- a/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.pcss @@ -24,7 +24,7 @@ limitations under the License. .mx_SidebarUserSettingsTab_checkbox { margin-bottom: $spacing-8; - // override checkbox styles˚ + /* override checkbox styles */ label { align-items: flex-start !important; } diff --git a/res/css/views/voip/_CallView.pcss b/res/css/views/voip/_CallView.pcss index 4e534484d887..44cbbfd0b137 100644 --- a/res/css/views/voip/_CallView.pcss +++ b/res/css/views/voip/_CallView.pcss @@ -20,12 +20,8 @@ limitations under the License. display: flex; flex-direction: column; - margin: var(--container-gap-width); - margin-right: calc(var(--container-gap-width) / 2); background-color: $header-panel-bg-color; - padding: 8px; - border-radius: 8px; .mx_AppTile { width: auto; diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 index b3b20e9aa388..c9ecd7a0dad3 100644 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 differ diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 index 48f66733aa3f..90f444b1a10b 100644 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 differ diff --git a/res/themes/light/css/_fonts.pcss b/res/themes/light/css/_fonts.pcss index 64a81ce48d36..62613fcee5ac 100644 --- a/res/themes/light/css/_fonts.pcss +++ b/res/themes/light/css/_fonts.pcss @@ -16,7 +16,8 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-weight: 400; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Regular.woff2?v=3.18") format("woff2"), + src: + url("$(res)/fonts/Inter/Inter-Regular.woff2?v=3.18") format("woff2"), url("$(res)/fonts/Inter/Inter-Regular.woff?v=3.18") format("woff"); } @font-face { @@ -25,7 +26,8 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-weight: 400; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Italic.woff2?v=3.18") format("woff2"), + src: + url("$(res)/fonts/Inter/Inter-Italic.woff2?v=3.18") format("woff2"), url("$(res)/fonts/Inter/Inter-Italic.woff?v=3.18") format("woff"); } @@ -35,7 +37,8 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-weight: 500; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Medium.woff2?v=3.18") format("woff2"), + src: + url("$(res)/fonts/Inter/Inter-Medium.woff2?v=3.18") format("woff2"), url("$(res)/fonts/Inter/Inter-Medium.woff?v=3.18") format("woff"); } @font-face { @@ -44,7 +47,8 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-weight: 500; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-MediumItalic.woff2?v=3.18") format("woff2"), + src: + url("$(res)/fonts/Inter/Inter-MediumItalic.woff2?v=3.18") format("woff2"), url("$(res)/fonts/Inter/Inter-MediumItalic.woff?v=3.18") format("woff"); } @@ -54,7 +58,8 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-weight: 600; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-SemiBold.woff2?v=3.18") format("woff2"), + src: + url("$(res)/fonts/Inter/Inter-SemiBold.woff2?v=3.18") format("woff2"), url("$(res)/fonts/Inter/Inter-SemiBold.woff?v=3.18") format("woff"); } @font-face { @@ -63,7 +68,8 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-weight: 600; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff2?v=3.18") format("woff2"), + src: + url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff2?v=3.18") format("woff2"), url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff?v=3.18") format("woff"); } @@ -73,7 +79,8 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-weight: 700; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Bold.woff2?v=3.18") format("woff2"), + src: + url("$(res)/fonts/Inter/Inter-Bold.woff2?v=3.18") format("woff2"), url("$(res)/fonts/Inter/Inter-Bold.woff?v=3.18") format("woff"); } @font-face { @@ -82,7 +89,8 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-weight: 700; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-BoldItalic.woff2?v=3.18") format("woff2"), + src: + url("$(res)/fonts/Inter/Inter-BoldItalic.woff2?v=3.18") format("woff2"), url("$(res)/fonts/Inter/Inter-BoldItalic.woff?v=3.18") format("woff"); } @@ -91,7 +99,9 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-family: "Inconsolata"; font-style: normal; font-weight: 400; - src: local("Inconsolata Regular"), local("Inconsolata-Regular"), + src: + local("Inconsolata Regular"), + local("Inconsolata-Regular"), url("$(res)/fonts/Inconsolata/QldKNThLqRwH-OJ1UHjlKGlX5qhExfHwNJU.woff2") format("woff2"); unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -101,7 +111,9 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-style: normal; font-weight: 400; font-display: swap; - src: local("Inconsolata Regular"), local("Inconsolata-Regular"), + src: + local("Inconsolata Regular"), + local("Inconsolata-Regular"), url("$(res)/fonts/Inconsolata/QldKNThLqRwH-OJ1UHjlKGlZ5qhExfHw.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; @@ -112,7 +124,9 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-style: normal; font-weight: 700; font-display: swap; - src: local("Inconsolata Bold"), local("Inconsolata-Bold"), + src: + local("Inconsolata Bold"), + local("Inconsolata-Bold"), url("$(res)/fonts/Inconsolata/QldXNThLqRwH-OJ1UHjlKGHiw71n5_zaDpwm80E.woff2") format("woff2"); unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -122,7 +136,9 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 font-style: normal; font-weight: 700; font-display: swap; - src: local("Inconsolata Bold"), local("Inconsolata-Bold"), + src: + local("Inconsolata Bold"), + local("Inconsolata-Bold"), url("$(res)/fonts/Inconsolata/QldXNThLqRwH-OJ1UHjlKGHiw71p5_zaDpwm.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; diff --git a/scripts/ci/layered.sh b/scripts/ci/layered.sh index b55cb7764785..aeb3694de15a 100755 --- a/scripts/ci/layered.sh +++ b/scripts/ci/layered.sh @@ -42,6 +42,7 @@ yarn install --frozen-lockfile # Finally, set up element-web scripts/fetchdep.sh vector-im element-web develop pushd element-web +[ -n "$ELEMENT_WEB_GITHUB_BASE_REF" ] && git fetch --depth 1 origin $ELEMENT_WEB_GITHUB_BASE_REF && git checkout $ELEMENT_WEB_GITHUB_BASE_REF yarn link matrix-js-sdk yarn link matrix-react-sdk yarn install --frozen-lockfile diff --git a/sonar-project.properties b/sonar-project.properties index b127fee1e985..ba78813445f6 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,12 +5,12 @@ sonar.organization=matrix-org #sonar.sourceEncoding=UTF-8 sonar.sources=src,res -sonar.tests=test,cypress +sonar.tests=test,playwright sonar.exclusions=__mocks__,docs sonar.cpd.exclusions=src/i18n/strings/*.json sonar.typescript.tsconfigPath=./tsconfig.json sonar.javascript.lcov.reportPaths=coverage/lcov.info # instrumentation is disabled on SessionLock -sonar.coverage.exclusions=test/**/*,cypress/**/*,src/components/views/dialogs/devtools/**/*,src/utils/SessionLock.ts +sonar.coverage.exclusions=test/**/*,playwright/**/*,src/components/views/dialogs/devtools/**/*,src/utils/SessionLock.ts sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml diff --git a/src/@types/common.ts b/src/@types/common.ts index ba2401f6a8c2..4169429bbe09 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -29,8 +29,8 @@ export type RecursivePartial = { [P in keyof T]?: T[P] extends (infer U)[] ? RecursivePartial[] : T[P] extends object - ? RecursivePartial - : T[P]; + ? RecursivePartial + : T[P]; }; export type KeysStartingWith = { @@ -51,10 +51,10 @@ export type Defaultize = P extends any export type DeepReadonly = T extends (infer R)[] ? DeepReadonlyArray : T extends Function - ? T - : T extends object - ? DeepReadonlyObject - : T; + ? T + : T extends object + ? DeepReadonlyObject + : T; interface DeepReadonlyArray extends ReadonlyArray> {} diff --git a/cypress/plugins/utils/homeserver.ts b/src/@types/react.d.ts similarity index 60% rename from cypress/plugins/utils/homeserver.ts rename to src/@types/react.d.ts index d6a4de041145..f1f57c5400a2 100644 --- a/cypress/plugins/utils/homeserver.ts +++ b/src/@types/react.d.ts @@ -14,15 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -/// +import React, { PropsWithChildren } from "react"; -export interface HomeserverConfig { - configDir: string; - registrationSecret: string; - baseUrl: string; - port: number; -} - -export interface HomeserverInstance extends HomeserverConfig { - serverId: string; +declare module "react" { + // Fix forwardRef types for Generic components - https://stackoverflow.com/a/58473012 + function forwardRef( + render: (props: PropsWithChildren

, ref: React.ForwardedRef) => React.ReactElement | null, + ): (props: P & React.RefAttributes) => React.ReactElement | null; } diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index 668a3bc6fc75..96526c034a9f 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -23,7 +23,10 @@ import { PosthogAnalytics } from "./PosthogAnalytics"; export class DecryptionFailure { public readonly ts: number; - public constructor(public readonly failedEventId: string, public readonly errorCode: string) { + public constructor( + public readonly failedEventId: string, + public readonly errorCode: string, + ) { this.ts = Date.now(); } } @@ -110,7 +113,10 @@ export class DecryptionFailureTracker { * @param {function?} errorCodeMapFn The function used to map error codes to the * trackedErrorCode. If not provided, the `.code` of errors will be used. */ - private constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn: ErrCodeMapFn) { + private constructor( + private readonly fn: TrackingFn, + private readonly errorCodeMapFn: ErrCodeMapFn, + ) { if (!fn || typeof fn !== "function") { throw new Error("DecryptionFailureTracker requires tracking function"); } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 78dcd4a13965..9a37e76ca84d 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -327,7 +327,10 @@ const topicSanitizeHtmlParams: IExtendedSanitizeOptions = { }; abstract class BaseHighlighter { - public constructor(public highlightClass: string, public highlightLink?: string) {} + public constructor( + public highlightClass: string, + public highlightLink?: string, + ) {} /** * Apply the highlights to a section of text diff --git a/src/IdentityAuthClient.tsx b/src/IdentityAuthClient.tsx index e4bbb76705bc..9dc6ebd84395 100644 --- a/src/IdentityAuthClient.tsx +++ b/src/IdentityAuthClient.tsx @@ -57,8 +57,13 @@ export default class IdentityAuthClient { } } + // This client must not be used for general operations as it may not have a baseUrl or be running (tempClient). + private get identityClient(): MatrixClient { + return this.tempClient ?? this.matrixClient; + } + private get matrixClient(): MatrixClient { - return this.tempClient ? this.tempClient : MatrixClientPeg.safeGet(); + return MatrixClientPeg.safeGet(); } private writeToken(): void { @@ -117,10 +122,10 @@ export default class IdentityAuthClient { } private async checkToken(token: string): Promise { - const identityServerUrl = this.matrixClient.getIdentityServerUrl()!; + const identityServerUrl = this.identityClient.getIdentityServerUrl()!; try { - await this.matrixClient.getIdentityAccount(token); + await this.identityClient.getIdentityAccount(token); } catch (e) { if (e instanceof MatrixError && e.errcode === "M_TERMS_NOT_SIGNED") { logger.log("Identity server requires new terms to be agreed to"); @@ -171,7 +176,8 @@ export default class IdentityAuthClient { public async registerForToken(check = true): Promise { const hsOpenIdToken = await MatrixClientPeg.safeGet().getOpenIdToken(); // XXX: The spec is `token`, but we used `access_token` for a Sydent release. - const { access_token: accessToken, token } = await this.matrixClient.registerWithIdentityServer(hsOpenIdToken); + const { access_token: accessToken, token } = + await this.identityClient.registerWithIdentityServer(hsOpenIdToken); const identityAccessToken = token ? token : accessToken; if (check) await this.checkToken(identityAccessToken); return identityAccessToken; diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 0f2435ffe82b..1d6577ca3992 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -23,7 +23,7 @@ import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; import { QueryDict } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; -import { MINIMUM_MATRIX_VERSION } from "matrix-js-sdk/src/version-support"; +import { MINIMUM_MATRIX_VERSION, SUPPORTED_MATRIX_VERSIONS } from "matrix-js-sdk/src/version-support"; import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg"; import SecurityCustomisations from "./customisations/Security"; @@ -635,7 +635,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): }, false, ); - checkServerVersions(); + await checkServerVersions(); return true; } else { logger.log("No previous session found."); @@ -644,29 +644,34 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): } async function checkServerVersions(): Promise { - MatrixClientPeg.get() - ?.getVersions() - .then((response) => { - if (!response.versions.includes(MINIMUM_MATRIX_VERSION)) { - const toastKey = "LEGACY_SERVER"; - ToastStore.sharedInstance().addOrReplaceToast({ - key: toastKey, - title: _t("unsupported_server_title"), - props: { - description: _t("unsupported_server_description", { - version: MINIMUM_MATRIX_VERSION, - brand: SdkConfig.get().brand, - }), - acceptLabel: _t("action|ok"), - onAccept: () => { - ToastStore.sharedInstance().dismissToast(toastKey); - }, - }, - component: GenericToast, - priority: 98, - }); - } - }); + const client = MatrixClientPeg.get(); + if (!client) return; + for (const version of SUPPORTED_MATRIX_VERSIONS) { + // Check if the server supports this spec version. (`isVersionSupported` caches the response, so this loop will + // only make a single HTTP request). + if (await client.isVersionSupported(version)) { + // we found a compatible spec version + return; + } + } + + const toastKey = "LEGACY_SERVER"; + ToastStore.sharedInstance().addOrReplaceToast({ + key: toastKey, + title: _t("unsupported_server_title"), + props: { + description: _t("unsupported_server_description", { + version: MINIMUM_MATRIX_VERSION, + brand: SdkConfig.get().brand, + }), + acceptLabel: _t("action|ok"), + onAccept: () => { + ToastStore.sharedInstance().dismissToast(toastKey); + }, + }, + component: GenericToast, + priority: 98, + }); } async function handleLoadSessionFailure(e: unknown): Promise { diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 4ea635b5b9ef..d09b8467fdb2 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -51,6 +51,8 @@ import MatrixClientBackedController from "./settings/controllers/MatrixClientBac import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import PlatformPeg from "./PlatformPeg"; import { formatList } from "./utils/FormattingUtils"; +import SdkConfig from "./SdkConfig"; +import { Features } from "./settings/Settings"; export interface IMatrixClientCreds { homeserverUrl: string; @@ -212,12 +214,21 @@ class MatrixClientPegClass implements IMatrixClientPeg { // If the user is not a guest then prompt them to reload rather than doing it for them // For guests this is likely to happen during e-mail verification as part of registration - const { finished } = Modal.createDialog(ErrorDialog, { - title: _t("error_database_closed_title"), - description: _t("error_database_closed_description"), + const brand = SdkConfig.get().brand; + const platform = PlatformPeg.get()?.getHumanReadableName(); + + // Determine the description based on the platform + const description = + platform === "Web Platform" + ? _t("error_database_closed_description|for_web", { brand }) + : _t("error_database_closed_description|for_desktop"); + + const [reload] = await Modal.createDialog(ErrorDialog, { + title: _t("error_database_closed_title", { brand }), + description, button: _t("action|reload"), - }); - const [reload] = await finished; + }).finished; + if (!reload) return; } @@ -291,7 +302,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { throw new Error("createClient must be called first"); } - const useRustCrypto = SettingsStore.getValue("feature_rust_crypto"); + const useRustCrypto = SettingsStore.getValue(Features.RustCrypto); // we want to make sure that the same crypto implementation is used throughout the lifetime of a device, // so persist the setting at the device layer diff --git a/src/Modal.tsx b/src/Modal.tsx index 801d617d315e..f2835799fd38 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -20,6 +20,7 @@ import ReactDOM from "react-dom"; import classNames from "classnames"; import { defer, sleep } from "matrix-js-sdk/src/utils"; import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; +import { TooltipProvider } from "@vector-im/compound-web"; import dis from "./dispatcher/dispatcher"; import AsyncWrapper from "./AsyncWrapper"; @@ -373,14 +374,16 @@ export class ModalManager extends TypedEventEmitter -

{this.staticModal.elem}
-
-
+ +
+
{this.staticModal.elem}
+
+
+ ); ReactDOM.render(staticDialog, ModalManager.getOrCreateStaticContainer()); @@ -396,14 +399,16 @@ export class ModalManager extends TypedEventEmitter -
{modal.elem}
-
-
+ +
+
{modal.elem}
+
+
+ ); setImmediate(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer())); diff --git a/src/NodeAnimator.tsx b/src/NodeAnimator.tsx index ec0602b3b5bb..85151bf3b06c 100644 --- a/src/NodeAnimator.tsx +++ b/src/NodeAnimator.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { Key, ReactElement, ReactFragment, ReactInstance, ReactPortal } from "react"; +import React, { Key, MutableRefObject, ReactElement, ReactFragment, ReactInstance, ReactPortal } from "react"; import ReactDom from "react-dom"; interface IChildProps { @@ -31,6 +31,8 @@ interface IProps { // a list of state objects to apply to each child node in turn startStyles: React.CSSProperties[]; + + innerRef?: MutableRefObject; } function isReactElement(c: ReactElement | ReactFragment | ReactPortal): c is ReactElement { @@ -123,6 +125,10 @@ export default class NodeAnimator extends React.Component { }, 0); } this.nodes[k] = node; + + if (this.props.innerRef) { + this.props.innerRef.current = node; + } } public render(): React.ReactNode { diff --git a/src/Notifier.ts b/src/Notifier.ts index 4218aba9acf0..bdd2d6aeb1c4 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -481,7 +481,7 @@ class NotifierClass { const room = MatrixClientPeg.safeGet().getRoom(roomId); if (!room) { // e.g we are in the process of joining a room. - // Seen in the cypress lazy-loading test. + // Seen in the Playwright lazy-loading test. return; } diff --git a/src/PosthogTrackers.ts b/src/PosthogTrackers.ts index 14bd8426de6b..18b6edc064a8 100644 --- a/src/PosthogTrackers.ts +++ b/src/PosthogTrackers.ts @@ -91,7 +91,7 @@ export default class PosthogTrackers { this.trackPage(); } - public static trackInteraction(name: InteractionName, ev?: SyntheticEvent, index?: number): void { + public static trackInteraction(name: InteractionName, ev?: SyntheticEvent | Event, index?: number): void { let interactionType: InteractionEvent["interactionType"]; if (ev?.type === "click") { interactionType = "Pointer"; diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index aa696f6e29ce..66be248f4f9e 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -24,7 +24,7 @@ import { } from "matrix-js-sdk/src/matrix"; import type { IPushRule, Room, MatrixClient } from "matrix-js-sdk/src/matrix"; -import { NotificationColor } from "./stores/notifications/NotificationColor"; +import { NotificationLevel } from "./stores/notifications/NotificationLevel"; import { getUnsentMessages } from "./components/structures/RoomStatusBar"; import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread"; import { EffectiveMembership, getEffectiveMembership, isKnockDenied } from "./utils/membership"; @@ -80,10 +80,19 @@ export function setRoomNotifsState(client: MatrixClient, roomId: string, newStat } } -export function getUnreadNotificationCount(room: Room, type: NotificationCountType, threadId?: string): number { +export function getUnreadNotificationCount( + room: Room, + type: NotificationCountType, + includeThreads: boolean, + threadId?: string, +): number { + const getCountShownForRoom = (r: Room, type: NotificationCountType): number => { + return includeThreads ? r.getUnreadNotificationCount(type) : r.getRoomUnreadNotificationCount(type); + }; + let notificationCount = !!threadId ? room.getThreadUnreadNotificationCount(threadId, type) - : room.getUnreadNotificationCount(type); + : getCountShownForRoom(room, type); // Check notification counts in the old room just in case there's some lost // there. We only go one level down to avoid performance issues, and theory @@ -99,7 +108,7 @@ export function getUnreadNotificationCount(room: Room, type: NotificationCountTy // notifying the user for unread messages because they would have extreme // difficulty changing their notification preferences away from "All Messages" // and "Noisy". - notificationCount += oldRoom.getUnreadNotificationCount(NotificationCountType.Highlight); + notificationCount += getCountShownForRoom(oldRoom, NotificationCountType.Highlight); } } @@ -224,40 +233,54 @@ function isMuteRule(rule: IPushRule): boolean { ); } +/** + * Returns an object giving information about the unread state of a room or thread + * @param room The room to query, or the room the thread is in + * @param threadId The thread to check the unread state of, or undefined to query the main thread + * @param includeThreads If threadId is undefined, true to include threads other than the main thread, or + * false to exclude them. Ignored if threadId is specified. + * @returns + */ export function determineUnreadState( room?: Room, threadId?: string, -): { color: NotificationColor; symbol: string | null; count: number } { + includeThreads?: boolean, +): { level: NotificationLevel; symbol: string | null; count: number } { if (!room) { - return { symbol: null, count: 0, color: NotificationColor.None }; + return { symbol: null, count: 0, level: NotificationLevel.None }; } if (getUnsentMessages(room, threadId).length > 0) { - return { symbol: "!", count: 1, color: NotificationColor.Unsent }; + return { symbol: "!", count: 1, level: NotificationLevel.Unsent }; } if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) { - return { symbol: "!", count: 1, color: NotificationColor.Red }; + return { symbol: "!", count: 1, level: NotificationLevel.Highlight }; } if (SettingsStore.getValue("feature_ask_to_join") && isKnockDenied(room)) { - return { symbol: "!", count: 1, color: NotificationColor.Red }; + return { symbol: "!", count: 1, level: NotificationLevel.Highlight }; } if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) { - return { symbol: null, count: 0, color: NotificationColor.None }; + return { symbol: null, count: 0, level: NotificationLevel.None }; } - const redNotifs = getUnreadNotificationCount(room, NotificationCountType.Highlight, threadId); - const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, threadId); + const redNotifs = getUnreadNotificationCount( + room, + NotificationCountType.Highlight, + includeThreads ?? false, + threadId, + ); + const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, includeThreads ?? false, threadId); const trueCount = greyNotifs || redNotifs; if (redNotifs > 0) { - return { symbol: null, count: trueCount, color: NotificationColor.Red }; + return { symbol: null, count: trueCount, level: NotificationLevel.Highlight }; } if (greyNotifs > 0) { - return { symbol: null, count: trueCount, color: NotificationColor.Grey }; + return { symbol: null, count: trueCount, level: NotificationLevel.Notification }; } // We don't have any notified messages, but we might have unread messages. Let's find out. @@ -269,12 +292,12 @@ export function determineUnreadState( } // If the thread does not exist, assume it contains no unreads } else { - hasUnread = doesRoomHaveUnreadMessages(room); + hasUnread = doesRoomHaveUnreadMessages(room, includeThreads ?? false); } return { symbol: null, count: trueCount, - color: hasUnread ? NotificationColor.Bold : NotificationColor.None, + level: hasUnread ? NotificationLevel.Activity : NotificationLevel.None, }; } diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index 56069c210316..5be7a5d86a87 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -34,7 +34,10 @@ export default class ScalarAuthClient { private termsInteractionCallback?: TermsInteractionCallback; private isDefaultManager: boolean; - public constructor(private apiUrl: string, private uiUrl: string) { + public constructor( + private apiUrl: string, + private uiUrl: string, + ) { this.scalarToken = null; // `undefined` to allow `startTermsFlow` to fallback to a default // callback if this is unset. diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 056be7a2a922..ff8946614fd8 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -299,6 +299,28 @@ export async function promptForBackupPassphrase(): Promise { return key; } +/** + * Carry out an operation that may require multiple accesses to secret storage, caching the key. + * + * Use this helper to wrap an operation that may require multiple accesses to secret storage; the user will be prompted + * to enter the 4S key or passphrase on the first access, and the key will be cached for the rest of the operation. + * + * @param func - The operation to be wrapped. + */ +export async function withSecretStorageKeyCache(func: () => Promise): Promise { + secretStorageBeingAccessed = true; + try { + return await func(); + } finally { + // Clear secret storage key cache now that work is complete + secretStorageBeingAccessed = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } +} + /** * This helper should be used whenever you need to access secret storage. It * ensures that secret storage (and also cross-signing since they each depend on @@ -319,14 +341,13 @@ export async function promptForBackupPassphrase(): Promise { * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. * @param {bool} [forceReset] Reset secret storage even if it's already set up - * @param {bool} [setupNewKeyBackup] Reset secret storage even if it's already set up */ -export async function accessSecretStorage( - func = async (): Promise => {}, - forceReset = false, - setupNewKeyBackup = true, -): Promise { - secretStorageBeingAccessed = true; +export async function accessSecretStorage(func = async (): Promise => {}, forceReset = false): Promise { + await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset)); +} + +/** Helper for {@link #accessSecretStorage} */ +async function doAccessSecretStorage(func: () => Promise, forceReset: boolean): Promise { try { const cli = MatrixClientPeg.safeGet(); if (!(await cli.hasSecretStorageKey()) || forceReset) { @@ -377,7 +398,6 @@ export async function accessSecretStorage( }); await crypto.bootstrapSecretStorage({ getKeyBackupPassphrase: promptForBackupPassphrase, - setupNewKeyBackup, }); const keyId = Object.keys(secretStorageKeys)[0]; @@ -403,13 +423,6 @@ export async function accessSecretStorage( logger.error(e); // Re-throw so that higher level logic can abort as needed throw e; - } finally { - // Clear secret storage key cache now that work is complete - secretStorageBeingAccessed = false; - if (!isCachingAllowed()) { - secretStorageKeys = {}; - secretStorageKeyInfo = {}; - } } } diff --git a/src/Terms.ts b/src/Terms.ts index 41400ef8724b..afa65248280c 100644 --- a/src/Terms.ts +++ b/src/Terms.ts @@ -33,7 +33,11 @@ export class Service { * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} accessToken The user's access token for the service */ - public constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) {} + public constructor( + public serviceType: SERVICE_TYPES, + public baseUrl: string, + public accessToken: string, + ) {} } export interface LocalisedPolicy { diff --git a/src/Unread.ts b/src/Unread.ts index 7b2da855dd70..3a177fedaec8 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -50,14 +50,19 @@ export function eventTriggersUnreadCount(client: MatrixClient, ev: MatrixEvent): return haveRendererForEvent(ev, client, false /* hidden messages should never trigger unread counts anyways */); } -export function doesRoomHaveUnreadMessages(room: Room): boolean { +export function doesRoomHaveUnreadMessages(room: Room, includeThreads: boolean): boolean { if (SettingsStore.getValue("feature_sliding_sync")) { // TODO: https://github.com/vector-im/element-web/issues/23207 // Sliding Sync doesn't support unread indicator dots (yet...) return false; } - for (const withTimeline of [room, ...room.getThreads()]) { + const toCheck: Array = [room]; + if (includeThreads) { + toCheck.push(...room.getThreads()); + } + + for (const withTimeline of toCheck) { if (doesTimelineHaveUnreadMessages(room, withTimeline.timeline)) { // We found an unread, so the room is unread return true; diff --git a/src/UserActivity.ts b/src/UserActivity.ts index ae6417d4f4d8..e65f627e767d 100644 --- a/src/UserActivity.ts +++ b/src/UserActivity.ts @@ -45,7 +45,10 @@ export default class UserActivity { private lastScreenX = 0; private lastScreenY = 0; - public constructor(private readonly window: Window, private readonly document: Document) { + public constructor( + private readonly window: Window, + private readonly document: Document, + ) { this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS); this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS); } diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx index 090b42333ff8..6ef6afef379c 100644 --- a/src/accessibility/context_menu/ContextMenuButton.tsx +++ b/src/accessibility/context_menu/ContextMenuButton.tsx @@ -16,25 +16,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ComponentProps, forwardRef, Ref } from "react"; import AccessibleButton from "../../components/views/elements/AccessibleButton"; -interface IProps extends React.ComponentProps { +type Props = ComponentProps> & { label?: string; - // whether or not the context menu is currently open + // whether the context menu is currently open isExpanded: boolean; -} +}; // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuButton: React.FC = ({ - label, - isExpanded, - children, - onClick, - onContextMenu, - ...props -}) => { +export const ContextMenuButton = forwardRef(function ( + { label, isExpanded, children, onClick, onContextMenu, ...props }: Props, + ref: Ref, +) { return ( = ({ aria-label={label} aria-haspopup={true} aria-expanded={isExpanded} + ref={ref} > {children} ); -}; +}); diff --git a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx index 750d47b08adf..349866276018 100644 --- a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx +++ b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx @@ -16,23 +16,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ComponentProps, forwardRef, Ref } from "react"; import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; -interface IProps extends React.ComponentProps { - // whether or not the context menu is currently open +type Props = ComponentProps> & { + // whether the context menu is currently open isExpanded: boolean; -} +}; // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuTooltipButton: React.FC = ({ - isExpanded, - children, - onClick, - onContextMenu, - ...props -}) => { +export const ContextMenuTooltipButton = forwardRef(function ( + { isExpanded, children, onClick, onContextMenu, ...props }: Props, + ref: Ref, +) { return ( = ({ aria-haspopup={true} aria-expanded={isExpanded} forceHide={isExpanded} + ref={ref} > {children} ); -}; +}); diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index 28748de73fb4..56c9052714ae 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -14,25 +14,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ComponentProps } from "react"; import AccessibleButton from "../../components/views/elements/AccessibleButton"; import { useRovingTabIndex } from "../RovingTabIndex"; import { Ref } from "./types"; -interface IProps extends Omit, "inputRef" | "tabIndex"> { +type Props = Omit< + ComponentProps>, + "inputRef" | "tabIndex" +> & { inputRef?: Ref; focusOnMouseOver?: boolean; -} +}; // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton: React.FC = ({ +export const RovingAccessibleButton = ({ inputRef, onFocus, onMouseOver, focusOnMouseOver, ...props -}) => { +}: Props): JSX.Element => { const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); return ( = ({ if (focusOnMouseOver) onFocusInternal(); onMouseOver?.(event); }} - inputRef={ref} + ref={ref} tabIndex={isActive ? 0 : -1} /> ); diff --git a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx index f06cc934bbc3..5607089c6e22 100644 --- a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx @@ -14,19 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ComponentProps } from "react"; import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; import { useRovingTabIndex } from "../RovingTabIndex"; import { Ref } from "./types"; -type ATBProps = React.ComponentProps; -interface IProps extends Omit { +type Props = Omit< + ComponentProps>, + "tabIndex" +> & { inputRef?: Ref; -} +}; // Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components. -export const RovingAccessibleTooltipButton: React.FC = ({ inputRef, onFocus, ...props }) => { +export const RovingAccessibleTooltipButton = ({ + inputRef, + onFocus, + ...props +}: Props): JSX.Element => { const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); return ( = ({ inputRef, onFo onFocusInternal(); onFocus?.(event); }} - inputRef={ref} + ref={ref} tabIndex={isActive ? 0 : -1} /> ); diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx index 5a7e317dd288..1dd280d085b9 100644 --- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx @@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { _t } from "../../../../languageHandler"; -import { accessSecretStorage } from "../../../../SecurityManager"; +import { accessSecretStorage, withSecretStorageKeyCache } from "../../../../SecurityManager"; import Spinner from "../../../../components/views/elements/Spinner"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; @@ -76,23 +76,34 @@ export default class CreateKeyBackupDialog extends React.PureComponent => { + // Check if 4S already set up + const secretStorageAlreadySetup = await cli.hasSecretStorageKey(); + + if (!secretStorageAlreadySetup) { + // bootstrap secret storage; that will also create a backup version + await accessSecretStorage(async (): Promise => { + // do nothing, all is now set up correctly + }); + } else { + await withSecretStorageKeyCache(async () => { const crypto = cli.getCrypto(); if (!crypto) { throw new Error("End-to-end encryption is disabled - unable to create backup."); } + + // Before we reset the backup, let's make sure we can access secret storage, to + // reduce the chance of us getting into a broken state where we have an outdated + // secret in secret storage. + // `SecretStorage.get` will ask the user to enter their passphrase/key if necessary; + // it will then be cached for the actual backup reset operation. + await cli.secretStorage.get("m.megolm_backup.v1"); + + // We now know we can store the new backup key in secret storage, so it is safe to + // go ahead with the reset. await crypto.resetKeyBackup(); - }, - forceReset, - setupNewKeyBackup, - ); + }); + } + this.setState({ phase: Phase.Done, }); diff --git a/src/audio/ManagedPlayback.ts b/src/audio/ManagedPlayback.ts index 6ecedd6766ad..175154557368 100644 --- a/src/audio/ManagedPlayback.ts +++ b/src/audio/ManagedPlayback.ts @@ -22,7 +22,11 @@ import { DEFAULT_WAVEFORM } from "./consts"; * A managed playback is a Playback instance that is guided by a PlaybackManager. */ export class ManagedPlayback extends Playback { - public constructor(private manager: PlaybackManager, buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { + public constructor( + private manager: PlaybackManager, + buf: ArrayBuffer, + seedWaveform = DEFAULT_WAVEFORM, + ) { super(buf, seedWaveform); } diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index a40e0a463cdf..dc2619d69227 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -72,7 +72,10 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte * @param {number[]} seedWaveform Optional seed waveform to present until the proper waveform * can be calculated. Contains values between zero and one, inclusive. */ - public constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { + public constructor( + private buf: ArrayBuffer, + seedWaveform = DEFAULT_WAVEFORM, + ) { super(); // Capture the file size early as reading the buffer will result in a 0-length buffer left behind this.fileSize = this.buf.byteLength; diff --git a/src/audio/VoiceMessageRecording.ts b/src/audio/VoiceMessageRecording.ts index b46fd0da23e9..de00bea31289 100644 --- a/src/audio/VoiceMessageRecording.ts +++ b/src/audio/VoiceMessageRecording.ts @@ -37,7 +37,10 @@ export class VoiceMessageRecording implements IDestroyable { private buffer = new Uint8Array(0); // use this.audioBuffer to access private playback?: Playback; - public constructor(private matrixClient: MatrixClient, private voiceRecording: VoiceRecording) { + public constructor( + private matrixClient: MatrixClient, + private voiceRecording: VoiceRecording, + ) { this.voiceRecording.onDataAvailable = this.onDataAvailable; } diff --git a/src/autocomplete/NotifProvider.tsx b/src/autocomplete/NotifProvider.tsx index 88eca2f096e0..b524454bc71f 100644 --- a/src/autocomplete/NotifProvider.tsx +++ b/src/autocomplete/NotifProvider.tsx @@ -28,7 +28,10 @@ import { TimelineRenderingType } from "../contexts/RoomContext"; const AT_ROOM_REGEX = /@\S*/g; export default class NotifProvider extends AutocompleteProvider { - public constructor(public room: Room, renderingType?: TimelineRenderingType) { + public constructor( + public room: Room, + renderingType?: TimelineRenderingType, + ) { super({ commandRegex: AT_ROOM_REGEX, renderingType }); } diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index a710b712314b..f12fdd004dea 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -57,7 +57,10 @@ function matcherObject( export default class RoomProvider extends AutocompleteProvider { protected matcher: QueryMatcher>; - public constructor(private readonly room: Room, renderingType?: TimelineRenderingType) { + public constructor( + private readonly room: Room, + renderingType?: TimelineRenderingType, + ) { super({ commandRegex: ROOM_REGEX, renderingType }); this.matcher = new QueryMatcher>([], { keys: ["displayedAlias", "matchName"], diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 8691c6c25d0e..26b52a6e728c 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -20,6 +20,7 @@ import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } fro import ReactDOM from "react-dom"; import classNames from "classnames"; import FocusLock from "react-focus-lock"; +import { TooltipProvider } from "@vector-im/compound-web"; import { Writeable } from "../../@types/common"; import UIStore from "../../stores/UIStore"; @@ -266,6 +267,7 @@ export default class ContextMenu extends React.PureComponent - - + + + + + ); ReactDOM.render(menu, getOrCreateContainer()); diff --git a/src/components/structures/GenericDropdownMenu.tsx b/src/components/structures/GenericDropdownMenu.tsx index 0a38db3dbe4b..e0fd3b7f9b66 100644 --- a/src/components/structures/GenericDropdownMenu.tsx +++ b/src/components/structures/GenericDropdownMenu.tsx @@ -195,7 +195,7 @@ export function GenericDropdownMenu({ <> { openMenu(); diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index d35d403e5f00..2547af77a1d8 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -26,7 +26,10 @@ import { import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import getEntryComponentForLoginType, { IStageComponent } from "../views/auth/InteractiveAuthEntryComponents"; +import getEntryComponentForLoginType, { + ContinueKind, + IStageComponent, +} from "../views/auth/InteractiveAuthEntryComponents"; import Spinner from "../views/elements/Spinner"; export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); @@ -59,7 +62,7 @@ export interface InteractiveAuthProps { continueIsManaged?: boolean; // continueText and continueKind are passed straight through to the AuthEntryComponent. continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; // callback makeRequest(auth: IAuthDict | null): Promise; // callback called when the auth process has finished, diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 60c3af16ac47..c2454e04fb0d 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -315,6 +315,8 @@ export default class LeftPanel extends React.Component { if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) { return ( @@ -356,6 +358,7 @@ export default class LeftPanel extends React.Component { onFocus={this.onFocus} onBlur={this.onBlur} onKeyDown={this.onKeyDown} + role="search" > @@ -397,7 +400,7 @@ export default class LeftPanel extends React.Component { selected={this.props.pageType === PageType.HomePage} minimized={this.props.isMinimized} /> -
+
+
); diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 3a0fbecdf375..5d3f5f5f6b5a 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -694,7 +694,7 @@ class LoggedInView extends React.Component {
- +
{pageElement}
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 0e7d8e866b63..33d41b63dbb7 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015-2022 The Matrix.org Foundation C.I.C. +Copyright 2015-2024 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ import { throttle } from "lodash"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { DecryptionError } from "matrix-js-sdk/src/crypto/algorithms"; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { TooltipProvider } from "@vector-im/compound-web"; // what-input helps improve keyboard accessibility import "what-input"; @@ -60,7 +61,6 @@ import { _t, _td, getCurrentLanguage } from "../../languageHandler"; import SettingsStore from "../../settings/SettingsStore"; import ThemeController from "../../settings/controllers/ThemeController"; import { startAnyRegistrationFlow } from "../../Registration"; -import { messageForSyncError } from "../../utils/ErrorUtils"; import ResizeNotifier from "../../utils/ResizeNotifier"; import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils"; import DMRoomMap from "../../utils/DMRoomMap"; @@ -113,7 +113,7 @@ import { PosthogAnalytics } from "../../PosthogAnalytics"; import { initSentry } from "../../sentry"; import LegacyCallHandler from "../../LegacyCallHandler"; import { showSpaceInvite } from "../../utils/space"; -import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; +import { ButtonEvent } from "../views/elements/AccessibleButton"; import { ActionPayload } from "../../dispatcher/payloads"; import { SummarizedNotificationState } from "../../stores/notifications/SummarizedNotificationState"; import Views from "../../Views"; @@ -140,13 +140,14 @@ import GenericToast from "../views/toasts/GenericToast"; import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog"; import { findDMForUser } from "../../utils/dm/findDMForUser"; import { Linkify } from "../../HtmlUtils"; -import { NotificationColor } from "../../stores/notifications/NotificationColor"; +import { NotificationLevel } from "../../stores/notifications/NotificationLevel"; import { UserTab } from "../views/dialogs/UserTab"; import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption"; import { Filter } from "../views/dialogs/spotlight/Filter"; import { checkSessionLockFree, getSessionLock } from "../../utils/SessionLock"; import { SessionLockStolenView } from "./auth/SessionLockStolenView"; import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"; +import { LoginSplashView } from "./auth/LoginSplashView"; // legacy export export { default as Views } from "../../Views"; @@ -2018,7 +2019,7 @@ export default class MatrixChat extends React.PureComponent { } if (numUnreadRooms > 0) { this.subTitleStatus += `[${numUnreadRooms}]`; - } else if (notificationState.color >= NotificationColor.Bold) { + } else if (notificationState.level >= NotificationLevel.Activity) { this.subTitleStatus += `*`; } @@ -2119,22 +2120,13 @@ export default class MatrixChat extends React.PureComponent { ); } else { // we think we are logged in, but are still waiting for the /sync to complete - let errorBox; - if (this.state.syncError && !isStoreError) { - errorBox = ( -
{messageForSyncError(this.state.syncError)}
- ); - } + // Suppress `InvalidStoreError`s here, since they have their own error dialog. view = ( -
- {errorBox} - -
- - {_t("action|logout")} - -
-
+ ); } } else if (this.state.view === Views.WELCOME) { @@ -2199,7 +2191,9 @@ export default class MatrixChat extends React.PureComponent { return ( - {view} + + {view} + ); } diff --git a/src/components/structures/PipContainer.tsx b/src/components/structures/PipContainer.tsx index 6f249fd67798..a7676f5bbce4 100644 --- a/src/components/structures/PipContainer.tsx +++ b/src/components/structures/PipContainer.tsx @@ -305,6 +305,7 @@ class PipContainerInner extends React.Component { const call = this.state.primaryCall; pipContent.push(({ onStartMoving, onResize }) => ( { if (this.state.showWidgetInPip && this.state.persistentWidgetId) { pipContent.push(({ onStartMoving }) => ( { private unmounted = false; private permalinkCreators: Record = {}; - private roomView = createRef(); + private roomView = createRef(); private searchResultsPanel = createRef(); private messagePanel: TimelinePanel | null = null; private roomViewBody = createRef(); @@ -1208,12 +1211,33 @@ export class RoomView extends React.Component { case Action.EditEvent: { // Quit early if we're trying to edit events in wrong rendering context if (payload.timelineRenderingType !== this.state.timelineRenderingType) return; + if (payload.event && payload.event.getRoomId() !== this.state.roomId) { + // If the event is in a different room (e.g. because the event to be edited is being displayed + // in the results of an all-rooms search), we need to view that room first. + dis.dispatch({ + action: Action.ViewRoom, + room_id: payload.event.getRoomId(), + metricsTrigger: undefined, + deferred_action: payload, + }); + return; + } + const editState = payload.event ? new EditorStateTransfer(payload.event) : undefined; - this.setState({ editState }, () => { - if (payload.event) { - this.messagePanel?.scrollToEventIfNeeded(payload.event.getId()); - } - }); + this.setState( + { + editState, + // If a search is active (implying that the "edit" button has been pressed on one of the + // events in the search result), we need to close that search, because RoomSearchView + // doesn't handle editing and won't render the composer. + search: undefined, + }, + () => { + if (payload.event) { + this.messagePanel?.scrollToEventIfNeeded(payload.event.getId()); + } + }, + ); break; } @@ -1407,8 +1431,6 @@ export class RoomView extends React.Component { tombstone: this.getRoomTombstone(room), liveTimeline: room.getLiveTimeline(), }); - - dis.dispatch({ action: Action.RoomLoaded }); }; private onRoomTimelineReset = (room?: Room): void => { @@ -2280,7 +2302,7 @@ export class RoomView extends React.Component { // if statusBar does not exist then statusBarArea is blank and takes up unnecessary space on the screen // show statusBarArea only if statusBar is present const statusBarArea = statusBar && ( -
+
{statusBar} @@ -2506,13 +2528,13 @@ export class RoomView extends React.Component { )} {auxPanel} -
+
{topUnreadMessagesBar} {jumpToBottom} {messagePanel} {searchResultsPanel} -
+ {statusBarArea} {previewBar} {messageComposer} @@ -2528,6 +2550,7 @@ export class RoomView extends React.Component { userId={this.context.client.getSafeUserId()} resizeNotifier={this.props.resizeNotifier} showApps={true} + role="main" /> {previewBar} @@ -2541,6 +2564,8 @@ export class RoomView extends React.Component { room={this.state.room} resizing={this.state.resizing} waitForCall={isVideoRoom(this.state.room)} + skipLobby={this.context.roomViewStore.skipCallLobby() ?? false} + role="main" /> {previewBar} @@ -2581,7 +2606,7 @@ export class RoomView extends React.Component { return ( -
{
- +
); } diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index aa57114e5acd..feeacb458184 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { + ComponentProps, Dispatch, KeyboardEvent, KeyboardEventHandler, @@ -349,7 +350,7 @@ const Tile: React.FC = ({ })} onClick={hasPermissions && onToggleClick ? onToggleClick : onPreviewClick} onKeyDown={onKeyDown} - inputRef={ref} + ref={ref} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > @@ -664,7 +665,7 @@ const ManageButtons: React.FC = ({ hierarchy, selected, set const disabled = !selectedRelations.length || removing || saving; let Button: React.ComponentType> = AccessibleButton; - let props = {}; + let props: Partial> = {}; if (!selectedRelations.length) { Button = AccessibleTooltipButton; props = { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 1acb6877f927..dc79a25489ac 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -16,7 +16,7 @@ limitations under the License. import { EventType, RoomType, JoinRule, Preset, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import React, { RefObject, useCallback, useContext, useRef, useState } from "react"; +import React, { useCallback, useContext, useRef, useState } from "react"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import createRoom, { IOpts } from "../../createRoom"; @@ -196,7 +196,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => { <> []; + const fieldRefs = [useRef(null), useRef(null), useRef(null)]; const [emailAddresses, setEmailAddress] = useStateArray(numFields, ""); const fields = new Array(numFields).fill(0).map((x, i) => { const name = "emailAddress" + i; diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 1357c0a81430..d43b4e25d16e 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -118,7 +118,7 @@ export const ThreadPanelHeader: React.FC<{ <> { openMenu(); @@ -180,11 +180,11 @@ const EmptyThread: React.FC = ({ hasThreads, filterOption, sh } return ( - +
); }; @@ -204,7 +204,8 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => useEffect(() => { const room = mxClient.getRoom(roomId); - room?.createThreadsTimelineSets() + room + ?.createThreadsTimelineSets() .then(() => room.fetchRoomThreads()) .then(() => { setFilterOption(ThreadFilterType.All); diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 2d82a5c412e6..1ed20add2052 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -453,7 +453,7 @@ export default class UserMenu extends React.Component { void; + + /** + * Error that caused `/sync` to fail. If set, an error message will be shown on the splash screen. + */ + syncError: Error | null; +} + +type MigrationState = { + progress: number; + totalSteps: number; +}; + +/** + * The view that is displayed after we have logged in, before the first /sync is completed. + */ +export function LoginSplashView(props: Props): React.JSX.Element { + const migrationState = useTypedEventEmitterState( + props.matrixClient, + CryptoEvent.LegacyCryptoStoreMigrationProgress, + (progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }), + ); + let errorBox: React.JSX.Element | undefined; + if (props.syncError) { + errorBox =
{messageForSyncError(props.syncError)}
; + } + + // If we are migrating the crypto data, show a progress bar. Otherwise, show a normal spinner. + let spinnerOrProgress; + if (migrationState.totalSteps !== -1) { + spinnerOrProgress = ( +
+

{_t("migrating_crypto")}

+ +
+ ); + } else { + spinnerOrProgress = ; + } + + return ( +
+ {errorBox} + {spinnerOrProgress} +
+ + {_t("action|logout")} + +
+
+ ); +} diff --git a/src/components/structures/auth/forgot-password/CheckEmail.tsx b/src/components/structures/auth/forgot-password/CheckEmail.tsx index 7ce5e6f20b01..d9d9c4ca8083 100644 --- a/src/components/structures/auth/forgot-password/CheckEmail.tsx +++ b/src/components/structures/auth/forgot-password/CheckEmail.tsx @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode, useRef } from "react"; +import React, { ReactNode } from "react"; +import { Tooltip } from "@vector-im/compound-web"; import AccessibleButton from "../../../views/elements/AccessibleButton"; import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg"; import { Icon as RetryIcon } from "../../../../../res/img/compound/retry-16px.svg"; import { _t } from "../../../../languageHandler"; -import Tooltip, { Alignment } from "../../../views/elements/Tooltip"; import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle"; import { ErrorMessage } from "../../ErrorMessage"; @@ -42,7 +42,6 @@ export const CheckEmail: React.FC = ({ onSubmitForm, onResendClick, }) => { - const tooltipId = useRef(`mx_CheckEmail_${Math.random()}`).current; const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500); const onResendClickFn = async (): Promise => { @@ -67,21 +66,12 @@ export const CheckEmail: React.FC = ({
{_t("auth|check_email_resend_prompt")} - - - {_t("action|resend")} - - + + + + {_t("action|resend")} + +
); diff --git a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx index a031b3da455e..11ede003403f 100644 --- a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx +++ b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode, useRef } from "react"; +import React, { ReactNode } from "react"; +import { Tooltip } from "@vector-im/compound-web"; import { _t } from "../../../../languageHandler"; import AccessibleButton from "../../../views/elements/AccessibleButton"; import { Icon as RetryIcon } from "../../../../../res/img/compound/retry-16px.svg"; import { Icon as EmailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg"; -import Tooltip, { Alignment } from "../../../views/elements/Tooltip"; import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle"; import { ErrorMessage } from "../../ErrorMessage"; @@ -40,7 +40,6 @@ export const VerifyEmailModal: React.FC = ({ onReEnterEmailClick, onResendClick, }) => { - const tooltipId = useRef(`mx_VerifyEmailModal_${Math.random()}`).current; const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500); const onResendClickFn = async (): Promise => { @@ -66,21 +65,12 @@ export const VerifyEmailModal: React.FC = ({
{_t("auth|check_email_resend_prompt")} - - - {_t("action|resend")} - - + + + + {_t("action|resend")} + + {errorText && }
diff --git a/src/components/views/audio_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx index 9cbd5a5c4292..63f266fbf74f 100644 --- a/src/components/views/audio_messages/PlayPauseButton.tsx +++ b/src/components/views/audio_messages/PlayPauseButton.tsx @@ -14,28 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; +import React, { ComponentProps, ReactNode } from "react"; import classNames from "classnames"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { _t } from "../../../languageHandler"; import { Playback, PlaybackState } from "../../../audio/Playback"; -// omitted props are handled by render function -interface IProps extends Omit, "title" | "onClick" | "disabled"> { +type Props = Omit< + ComponentProps, + "title" | "onClick" | "disabled" | "element" | "ref" +> & { // Playback instance to manipulate. Cannot change during the component lifecycle. playback: Playback; // The playback phase to render. Able to change during the component lifecycle. playbackPhase: PlaybackState; -} +}; /** * Displays a play/pause button (activating the play/pause function of the recorder) * to be displayed in reference to a recording. */ -export default class PlayPauseButton extends React.PureComponent { - public constructor(props: IProps) { +export default class PlayPauseButton extends React.PureComponent { + public constructor(props: Props) { super(props); } diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/SeekBar.tsx index 4af84325e58b..939e29df510e 100644 --- a/src/components/views/audio_messages/SeekBar.tsx +++ b/src/components/views/audio_messages/SeekBar.tsx @@ -19,6 +19,7 @@ import React, { ChangeEvent, CSSProperties, ReactNode } from "react"; import { PlaybackInterface } from "../../../audio/Playback"; import { MarkedExecution } from "../../../utils/MarkedExecution"; import { percentageOf } from "../../../utils/numbers"; +import { _t } from "../../../languageHandler"; interface IProps { // Playback instance to render. Cannot change during component lifecycle: create @@ -112,6 +113,7 @@ export default class SeekBar extends React.PureComponent { step={0.001} style={{ "--fillTo": this.state.percentage } as ISeekCSS} disabled={this.props.disabled} + aria-label={_t("a11y|seek_bar_label")} /> ); } diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index 9aaf02b09f7b..0a7ed19b2ab6 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -25,7 +25,7 @@ import { _t } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import { LocalisedPolicy, Policies } from "../../../Terms"; import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import Field from "../elements/Field"; import Spinner from "../elements/Spinner"; @@ -780,9 +780,12 @@ export class RegistrationTokenAuthEntry extends React.Component; + interface ISSOAuthEntryProps extends IAuthEntryProps { continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; onCancel?: () => void; } @@ -866,7 +869,7 @@ export class SSOAuthEntry extends React.Component {_t("action|cancel")} @@ -971,7 +974,7 @@ export class FallbackAuthEntry extends React.Component { } return (
- + {_t("auth|uia|fallback_button")} {errorSection} @@ -985,7 +988,7 @@ export interface IStageComponentProps extends IAuthEntryProps { inputs?: IInputs; stageState?: IStageStatus; continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; setEmailSid?(sid: string): void; onCancel?(): void; requestEmailToken?(): Promise; diff --git a/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx index 82e1e117064e..1e2efb5106f9 100644 --- a/src/components/views/auth/LoginWithQR.tsx +++ b/src/components/views/auth/LoginWithQR.tsx @@ -19,7 +19,7 @@ import { MSC3906Rendezvous, MSC3906RendezvousPayload, RendezvousFailureReason } import { MSC3886SimpleHttpRendezvousTransport } from "matrix-js-sdk/src/rendezvous/transports"; import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "matrix-js-sdk/src/rendezvous/channels"; import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { HTTPError, MatrixClient } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import { wrapRequestWithDialog } from "../../../utils/UserInteractiveAuth"; @@ -63,10 +63,16 @@ interface IState { phase: Phase; rendezvous?: MSC3906Rendezvous; confirmationDigits?: string; - failureReason?: RendezvousFailureReason; + failureReason?: FailureReason; mediaPermissionError?: boolean; } +export enum LoginWithQRFailureReason { + RateLimited = "rate_limited", +} + +export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason; + /** * A component that allows sign in and E2EE set up with a QR code. * @@ -136,16 +142,27 @@ export default class LoginWithQR extends React.Component { // user denied return; } - if (!this.props.client.crypto) { + if (!this.props.client.getCrypto()) { // no E2EE to set up this.props.onFinished(true); return; } this.setState({ phase: Phase.Verifying }); await this.state.rendezvous.verifyNewDeviceOnExistingDevice(); + // clean up our state: + try { + await this.state.rendezvous.close(); + } finally { + this.setState({ rendezvous: undefined }); + } this.props.onFinished(true); } catch (e) { logger.error("Error whilst approving sign in", e); + if (e instanceof HTTPError && e.httpStatus === 429) { + // 429: rate limit + this.setState({ phase: Phase.Error, failureReason: LoginWithQRFailureReason.RateLimited }); + return; + } this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); } }; diff --git a/src/components/views/auth/LoginWithQRFlow.tsx b/src/components/views/auth/LoginWithQRFlow.tsx index 511b4bca003a..526496246a99 100644 --- a/src/components/views/auth/LoginWithQRFlow.tsx +++ b/src/components/views/auth/LoginWithQRFlow.tsx @@ -25,13 +25,13 @@ import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.s import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg"; import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg"; import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; -import { Click, Phase } from "./LoginWithQR"; +import { Click, FailureReason, LoginWithQRFailureReason, Phase } from "./LoginWithQR"; interface IProps { phase: Phase; code?: string; onClick(type: Click): Promise; - failureReason?: RendezvousFailureReason; + failureReason?: FailureReason; confirmationDigits?: string; } @@ -102,6 +102,9 @@ export default class LoginWithQRFlow extends React.Component { case RendezvousFailureReason.UserCancelled: cancellationMessage = _t("auth|qr_code_login|error_request_cancelled"); break; + case LoginWithQRFailureReason.RateLimited: + cancellationMessage = _t("auth|qr_code_login|error_rate_limited"); + break; case RendezvousFailureReason.Unknown: cancellationMessage = _t("auth|qr_code_login|error_unexpected"); break; diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 174cdffbafdb..6f1635e17963 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -59,7 +59,7 @@ const enum LoginField { Email = "login_field_email", MatrixId = "login_field_mxid", Phone = "login_field_phone", - Password = "login_field_phone", + Password = "login_field_password", } /* @@ -70,6 +70,7 @@ export default class PasswordLogin extends React.PureComponent { private [LoginField.Email]: Field | null = null; private [LoginField.Phone]: Field | null = null; private [LoginField.MatrixId]: Field | null = null; + private [LoginField.Password]: Field | null = null; public static defaultProps = { onUsernameChanged: function () {}, @@ -149,7 +150,7 @@ export default class PasswordLogin extends React.PureComponent { activeElement.blur(); } - const fieldIDsInDisplayOrder: LoginField[] = [this.state.loginType, LoginField.Password]; + const fieldIDsInDisplayOrder = [this.state.loginType, LoginField.Password]; // Run all fields with stricter validation that no longer allows empty // values for required fields. diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 88cf24616c71..d956c26da28c 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -17,7 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useContext, useEffect, useState } from "react"; +import React, { forwardRef, useCallback, useContext, useEffect, useState } from "react"; import classNames from "classnames"; import { ClientEvent } from "matrix-js-sdk/src/matrix"; import { Avatar } from "@vector-im/compound-web"; @@ -38,7 +38,6 @@ interface IProps { type?: React.ComponentProps["type"]; size: string; onClick?: (ev: ButtonEvent) => void; - inputRef?: React.RefObject; className?: string; tabIndex?: number; altText?: string; @@ -95,7 +94,7 @@ const useImageUrl = ({ url, urls }: { url?: string | null; urls?: string[] }): [ return [imageUrl, onError]; }; -const BaseAvatar: React.FC = (props) => { +const BaseAvatar = forwardRef((props, ref) => { const { name, idName, @@ -104,7 +103,6 @@ const BaseAvatar: React.FC = (props) => { urls, size = "40px", onClick, - inputRef, className, type = "round", altText = _t("common|avatar"), @@ -127,7 +125,7 @@ const BaseAvatar: React.FC = (props) => { return ( = (props) => { data-testid="avatar-img" /> ); -}; +}); export default BaseAvatar; export type BaseAvatarType = React.FC; diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index 39997e186dbd..4b6a09442816 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -16,8 +16,9 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import { Room, RoomEvent, MatrixEvent, User, UserEvent, EventType, JoinRule } from "matrix-js-sdk/src/matrix"; +import { EventType, JoinRule, MatrixEvent, Room, RoomEvent, User, UserEvent } from "matrix-js-sdk/src/matrix"; import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; +import { Tooltip } from "@vector-im/compound-web"; import RoomAvatar from "./RoomAvatar"; import NotificationBadge from "../rooms/NotificationBadge"; @@ -26,10 +27,8 @@ import { NotificationState } from "../../../stores/notifications/NotificationSta import { isPresenceEnabled } from "../../../utils/presence"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from "../../../languageHandler"; -import TextWithTooltip from "../elements/TextWithTooltip"; import DMRoomMap from "../../../utils/DMRoomMap"; import { IOOBData } from "../../../stores/ThreepidInviteStore"; -import TooltipTarget from "../elements/TooltipTarget"; interface IProps { room: Room; @@ -38,7 +37,9 @@ interface IProps { forceCount?: boolean; oobData?: IOOBData; viewAvatarOnClick?: boolean; - tooltipProps?: Omit, "label" | "tooltipClassName" | "className">; + tooltipProps?: { + tabIndex?: number; + }; } interface IState { @@ -94,9 +95,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent ); } @@ -211,7 +209,11 @@ export default class DecoratedRoomAvatar extends React.PureComponent - {icon} + {icon && ( + + {icon} + + )} {badge}
); diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 678ca89ec3e6..15ff2f015c4d 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode, useContext } from "react"; +import React, { forwardRef, ReactNode, Ref, useContext } from "react"; import { RoomMember, ResizeMethod } from "matrix-js-sdk/src/matrix"; import dis from "../../../dispatcher/dispatcher"; @@ -42,16 +42,19 @@ interface IProps extends Omit, "name" | children?: ReactNode; } -export default function MemberAvatar({ - size, - resizeMethod = "crop", - viewUserOnClick, - forceHistorical, - fallbackUserId, - hideTitle, - member: propsMember, - ...props -}: IProps): JSX.Element { +function MemberAvatar( + { + size, + resizeMethod = "crop", + viewUserOnClick, + forceHistorical, + fallbackUserId, + hideTitle, + member: propsMember, + ...props + }: IProps, + ref: Ref, +): JSX.Element { const card = useContext(CardContext); const member = useRoomMemberProfile({ @@ -100,12 +103,9 @@ export default function MemberAvatar({ : props.onClick } altText={_t("common|user_avatar")} + ref={ref} /> ); } -export class LegacyMemberAvatar extends React.Component { - public render(): React.ReactNode { - return {this.props.children}; - } -} +export default forwardRef(MemberAvatar); diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index 68885fc693b4..2a7ad1814ac6 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -43,6 +43,7 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => action: Action.ViewRoom, room_id: roomId, view_call: true, + skipLobby: "shiftKey" in ev ? ev.shiftKey : false, metricsTrigger: undefined, }); }, diff --git a/src/components/views/beacon/ShareLatestLocation.tsx b/src/components/views/beacon/ShareLatestLocation.tsx index ba69628816cb..31f2a1387b9c 100644 --- a/src/components/views/beacon/ShareLatestLocation.tsx +++ b/src/components/views/beacon/ShareLatestLocation.tsx @@ -16,12 +16,12 @@ limitations under the License. import React, { useEffect, useState } from "react"; import { ContentHelpers } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; import { Icon as ExternalLinkIcon } from "../../../../res/img/external-link.svg"; import { _t } from "../../../languageHandler"; import { makeMapSiteLink, parseGeoUri } from "../../../utils/location"; import CopyableText from "../elements/CopyableText"; -import TooltipTarget from "../elements/TooltipTarget"; interface Props { latestLocationState?: ContentHelpers.BeaconLocationState; @@ -46,11 +46,11 @@ const ShareLatestLocation: React.FC = ({ latestLocationState }) => { return ( <> - + - + latLonString} /> ); diff --git a/src/components/views/context_menus/KebabContextMenu.tsx b/src/components/views/context_menus/KebabContextMenu.tsx index b81c6aef6f4f..7a6b09668dda 100644 --- a/src/components/views/context_menus/KebabContextMenu.tsx +++ b/src/components/views/context_menus/KebabContextMenu.tsx @@ -39,7 +39,7 @@ export const KebabContextMenu: React.FC = ({ options, tit return ( <> - + {menuDisplayed && ( diff --git a/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/src/components/views/context_menus/RoomGeneralContextMenu.tsx index 8e96c0287047..4cfd2ed60408 100644 --- a/src/components/views/context_menus/RoomGeneralContextMenu.tsx +++ b/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -26,7 +26,7 @@ import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { _t } from "../../../languageHandler"; -import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import DMRoomMap from "../../../utils/DMRoomMap"; @@ -212,9 +212,9 @@ export const RoomGeneralContextMenu: React.FC = ({ ); } - const { color } = useUnreadNotifications(room); + const { level } = useUnreadNotifications(room); const markAsReadOption: JSX.Element | null = - color > NotificationColor.None ? ( + level > NotificationLevel.None ? ( { clearRoomNotification(room, cli); diff --git a/src/components/views/context_menus/ThreadListContextMenu.tsx b/src/components/views/context_menus/ThreadListContextMenu.tsx index 0fb3831b2372..76abc23740ec 100644 --- a/src/components/views/context_menus/ThreadListContextMenu.tsx +++ b/src/components/views/context_menus/ThreadListContextMenu.tsx @@ -94,7 +94,7 @@ const ThreadListContextMenu: React.FC = ({ onClick={openMenu} title={_t("right_panel|thread_list|context_menu_label")} isExpanded={menuDisplayed} - inputRef={button} + ref={button} data-testid="threadlist-dropdown-button" /> {menuDisplayed && ( diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index cb903dbfd281..39a00f103472 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactElement, ReactNode, RefObject, useContext, useMemo, useRef, useState } from "react"; +import React, { ReactElement, ReactNode, useContext, useMemo, useRef, useState } from "react"; import classNames from "classnames"; import { Room, EventType } from "matrix-js-sdk/src/matrix"; import { sleep } from "matrix-js-sdk/src/utils"; @@ -144,7 +144,7 @@ export const AddExistingToSpace: React.FC = ({ [cli, msc3946ProcessDynamicPredecessor], ); - const scrollRef = useRef() as RefObject>; + const scrollRef = useRef>(null); const [scrollState, setScrollState] = useState({ // these are estimates which update as soon as it mounts scrollTop: 0, @@ -362,32 +362,31 @@ export const AddExistingToSpace: React.FC = ({ const defaultRendererFactory = (title: TranslationKey): Renderer => - (rooms, selectedToAdd, { scrollTop, height }, onChange) => - ( -
-

{_t(title)}

- ( - { - onChange(checked, room); - } - : undefined - } - /> - )} - /> -
- ); + (rooms, selectedToAdd, { scrollTop, height }, onChange) => ( +
+

{_t(title)}

+ ( + { + onChange(checked, room); + } + : undefined + } + /> + )} + /> +
+ ); export const defaultRoomsRenderer = defaultRendererFactory(_td("common|rooms")); export const defaultSpacesRenderer = defaultRendererFactory(_td("common|spaces")); diff --git a/src/components/views/dialogs/DeactivateAccountDialog.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx index 4af05a6d1a29..1415f3befac3 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.tsx +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -23,7 +23,7 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from "../../../languageHandler"; import InteractiveAuth, { ERROR_USER_CANCELLED, InteractiveAuthCallback } from "../../structures/InteractiveAuth"; -import { DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; +import { ContinueKind, DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import StyledCheckbox from "../elements/StyledCheckbox"; import BaseDialog from "./BaseDialog"; import defaultDispatcher from "../../../dispatcher/dispatcher"; @@ -34,7 +34,7 @@ type DialogAesthetics = Partial<{ [x: number]: { body: string; continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; }; }; }>; @@ -53,7 +53,7 @@ interface IState { // next to the InteractiveAuth component. bodyText?: string; continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; } export default class DeactivateAccountDialog extends React.Component { @@ -98,7 +98,7 @@ export default class DeactivateAccountDialog extends React.Component; @@ -146,7 +146,7 @@ export default class InteractiveAuthDialog extends React.Component { hasCancel={true} onFinished={this.onFinished} > - ; + ); diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 3b3d5991217f..bfe17eb4f26a 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -33,7 +33,7 @@ import { import BaseDialog from "./BaseDialog"; import { _t, getUserLanguage } from "../../../languageHandler"; -import AccessibleButton from "../elements/AccessibleButton"; +import AccessibleButton, { AccessibleButtonKind } from "../elements/AccessibleButton"; import { StopGapWidgetDriver } from "../../../stores/widgets/StopGapWidgetDriver"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; @@ -158,7 +158,7 @@ export default class ModalWidgetDialog extends React.PureComponent { - let kind = "secondary"; + let kind: AccessibleButtonKind = "secondary"; switch (def.kind) { case ModalButtonKind.Primary: kind = "primary"; diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index 793cfcf27a6f..ee8a3751daa4 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -182,7 +182,7 @@ export default class ServerPickerDialog extends React.PureComponent
); const warningTooltip = ( - - + ); // Due to i18n limitations, we can't dedupe the code for variables in these two messages. diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index 13fa3daac772..74317041bd2c 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -93,6 +93,13 @@ interface IProps { showLayoutButtons?: boolean; // Handle to manually notify the PersistedElement that it needs to move movePersistedElement?: MutableRefObject<(() => void) | undefined>; + // An element to render after the iframe as an overlay + overlay?: ReactNode; + // If defined this async method will be called when the widget requests to become sticky. + // It will only become sticky once the returned promise resolves. + // This is useful because: Widget B is sticky. Making widget A sticky will kill widget B immediately. + // This promise allows to do Widget B related cleanup before Widget A becomes sticky. (e.g. hangup a Voip call) + stickyPromise?: () => Promise; } interface IState { @@ -612,6 +619,8 @@ export default class AppTile extends React.Component { "mx_AppTileBody--large": !this.props.miniMode, "mx_AppTileBody--mini": this.props.miniMode, "mx_AppTileBody--loading": this.state.loading, + // We don't want mx_AppTileBody (rounded corners) for call widgets + "mx_AppTileBody--call": this.props.app.type === WidgetType.CALL.preferred, }); const appTileBodyStyles: CSSProperties = {}; if (this.props.pointerEvents) { @@ -661,17 +670,20 @@ export default class AppTile extends React.Component { ); } else { appTileBody = ( -
- {this.state.loading && loadingElement} -