diff --git a/.editorconfig b/.editorconfig index 98ebc4dc8f1..331e3938087 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,3 +24,6 @@ trim_trailing_whitespace = true [*.{yml,yaml}] indent_size = 4 + +[*.tsx.snap] +trim_trailing_whitespace = false diff --git a/.eslintrc.js b/.eslintrc.js index 568a106ee6f..4bec4e83203 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -36,6 +36,7 @@ module.exports = { ), ], + "import/no-duplicates": ["error"], // Ban matrix-js-sdk/src imports in favour of matrix-js-sdk/src/matrix imports to prevent unleashing hell. "no-restricted-imports": [ "error", @@ -72,8 +73,68 @@ module.exports = { ], patterns: [ { - group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"], - message: "Please use matrix-js-sdk/src/* instead", + group: [ + "matrix-js-sdk/src/**", + "!matrix-js-sdk/src/matrix", + "!matrix-js-sdk/src/crypto-api", + "!matrix-js-sdk/src/types", + "!matrix-js-sdk/src/testing", + "matrix-js-sdk/lib", + "matrix-js-sdk/lib/", + "matrix-js-sdk/lib/**", + // XXX: Temporarily allow these as they are not available via the main export + "!matrix-js-sdk/src/logger", + "!matrix-js-sdk/src/errors", + "!matrix-js-sdk/src/utils", + "!matrix-js-sdk/src/version-support", + "!matrix-js-sdk/src/randomstring", + "!matrix-js-sdk/src/sliding-sync", + "!matrix-js-sdk/src/browser-index", + "!matrix-js-sdk/src/feature", + "!matrix-js-sdk/src/NamespacedValue", + "!matrix-js-sdk/src/ReEmitter", + "!matrix-js-sdk/src/event-mapper", + "!matrix-js-sdk/src/interactive-auth", + "!matrix-js-sdk/src/secret-storage", + "!matrix-js-sdk/src/room-hierarchy", + "!matrix-js-sdk/src/rendezvous", + "!matrix-js-sdk/src/indexeddb-worker", + "!matrix-js-sdk/src/pushprocessor", + "!matrix-js-sdk/src/extensible_events_v1", + "!matrix-js-sdk/src/extensible_events_v1/PollStartEvent", + "!matrix-js-sdk/src/extensible_events_v1/PollResponseEvent", + "!matrix-js-sdk/src/extensible_events_v1/PollEndEvent", + "!matrix-js-sdk/src/extensible_events_v1/InvalidEventError", + "!matrix-js-sdk/src/crypto", + "!matrix-js-sdk/src/crypto/aes", + "!matrix-js-sdk/src/crypto/olmlib", + "!matrix-js-sdk/src/crypto/crypto", + "!matrix-js-sdk/src/crypto/keybackup", + "!matrix-js-sdk/src/crypto/RoomList", + "!matrix-js-sdk/src/crypto/deviceinfo", + "!matrix-js-sdk/src/crypto/key_passphrase", + "!matrix-js-sdk/src/crypto/CrossSigning", + "!matrix-js-sdk/src/crypto/recoverykey", + "!matrix-js-sdk/src/crypto/dehydration", + "!matrix-js-sdk/src/oidc", + "!matrix-js-sdk/src/oidc/discovery", + "!matrix-js-sdk/src/oidc/authorize", + "!matrix-js-sdk/src/oidc/validate", + "!matrix-js-sdk/src/oidc/error", + "!matrix-js-sdk/src/oidc/register", + "!matrix-js-sdk/src/webrtc", + "!matrix-js-sdk/src/webrtc/call", + "!matrix-js-sdk/src/webrtc/callFeed", + "!matrix-js-sdk/src/webrtc/mediaHandler", + "!matrix-js-sdk/src/webrtc/callEventTypes", + "!matrix-js-sdk/src/webrtc/callEventHandler", + "!matrix-js-sdk/src/webrtc/groupCallEventHandler", + "!matrix-js-sdk/src/models", + "!matrix-js-sdk/src/models/read-receipt", + "!matrix-js-sdk/src/models/relations-container", + "!matrix-js-sdk/src/models/related-relations", + ], + message: "Please use matrix-js-sdk/src/matrix instead", }, ], }, @@ -92,13 +153,12 @@ module.exports = { "jsx-a11y/no-noninteractive-tabindex": "off", "jsx-a11y/no-static-element-interactions": "off", "jsx-a11y/role-supports-aria-props": "off", - "jsx-a11y/tabindex-no-positive": "off", "matrix-org/require-copyright-header": "error", }, overrides: [ { - files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "cypress/**/*.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": [ @@ -162,21 +222,18 @@ module.exports = { }, }, { - files: ["test/**/*.{ts,tsx}", "cypress/**/*.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. "jest/no-disabled-tests": "off", - // TODO: There are many tests with invalid expects that should be fixed, - // https://github.com/vector-im/element-web/issues/24709 - "jest/valid-expect": "off", // Also treat "oldBackendOnly" as a test function. // Used in some crypto tests. "jest/no-standalone-expect": [ @@ -188,14 +245,9 @@ module.exports = { }, }, { - files: ["cypress/**/*.ts"], + files: ["playwright/**/*.ts"], parserOptions: { - project: ["./cypress/tsconfig.json"], - }, - rules: { - // Cypress "promises" work differently - disable some related rules - "jest/valid-expect-in-promise": "off", - "jest/no-done-callback": "off", + project: ["./playwright/tsconfig.json"], }, }, ], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 16574bad790..e7963c26735 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,17 @@ -* @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 + +# Ignore translations as those will be updated by GHA for Localazy download +/src/i18n/strings +# Ignore the synapse plugin as this is updated by GHA for docker image updating +/playwright/plugins/homeserver/synapse/index.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0527dcf64c6..d4997def594 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,20 +2,7 @@ ## Checklist -- [ ] Tests written for new code (and old code if feasible) -- [ ] Linter and other CI checks pass -- [ ] Sign-off given on the changes (see [CONTRIBUTING.md](https://github.com/matrix-org/matrix-react-sdk/blob/develop/CONTRIBUTING.md)) - - +- [ ] Tests written for new code (and old code if feasible). +- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation. +- [ ] Linter and other CI checks pass. +- [ ] Sign-off given on the changes (see [CONTRIBUTING.md](https://github.com/matrix-org/matrix-react-sdk/blob/develop/CONTRIBUTING.md)). diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 00000000000..e9d334f61b4 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1 @@ +_extends: matrix-org/matrix-js-sdk diff --git a/.github/workflows/end-to-end-tests-netlify.yaml b/.github/workflows/end-to-end-tests-netlify.yaml new file mode 100644 index 00000000000..a488cbbfb09 --- /dev/null +++ b/.github/workflows/end-to-end-tests-netlify.yaml @@ -0,0 +1,43 @@ +# Triggers after the playwright tests have finished, +# taking the artifact and uploading it to Netlify for easier viewing +name: Upload End to End Test report to Netlify +on: + workflow_run: + workflows: ["End to End Tests"] + types: + - completed + +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} + cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }} + +jobs: + report: + if: github.event.workflow_run.conclusion != 'cancelled' + name: Report results + runs-on: ubuntu-latest + environment: Netlify + permissions: + statuses: write + deployments: write + steps: + - name: Download HTML report + uses: actions/download-artifact@v4 + with: + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + name: html-report + path: playwright-report + + - name: 📤 Deploy to Netlify + uses: matrix-org/netlify-pr-preview@v3 + with: + path: playwright-report + owner: ${{ github.event.workflow_run.head_repository.owner.login }} + branch: ${{ github.event.workflow_run.head_branch }} + revision: ${{ github.event.workflow_run.head_sha }} + token: ${{ secrets.NETLIFY_AUTH_TOKEN }} + site_id: ${{ secrets.NETLIFY_SITE_ID }} + desc: Playwright Report + deployment_env: EndToEndTests + prefix: "e2e-" diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml new file mode 100644 index 00000000000..b663948254b --- /dev/null +++ b/.github/workflows/end-to-end-tests.yaml @@ -0,0 +1,206 @@ +# Produce a build of element-web with this version of react-sdk +# and any matching branches of element-web and js-sdk, output it +# as an artifact and run end-to-end tests. +name: End to End Tests +on: + pull_request: {} + merge_group: + types: [checks_requested] + push: + branches: [develop, master] + repository_dispatch: + types: [upstream-sdk-notify] + + # support triggering from other workflows + workflow_call: + inputs: + skip: + type: boolean + required: false + default: false + description: "A boolean to skip the playwright check itself while still creating the passing check. Useful when only running in Merge Queues." + + react-sdk-repository: + type: string + required: true + description: "The name of the github repository to check out and build." + + matrix-js-sdk-sha: + type: string + required: false + description: "The Git SHA of matrix-js-sdk to build against. By default, will use a matching branch name if it exists, or develop." + element-web-sha: + type: string + required: false + description: "The Git SHA of element-web to build against. By default, will use a matching branch name if it exists, or develop." + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +env: + # fetchdep.sh needs to know our PR number + PR_NUMBER: ${{ github.event.pull_request.number }} + +jobs: + build: + name: "Build Element-Web" + runs-on: ubuntu-latest + if: inputs.skip != true + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + repository: ${{ inputs.react-sdk-repository || github.repository }} + + - uses: actions/setup-node@v4 + with: + cache: "yarn" + + - name: Fetch layered build + id: layered_build + env: + # tell layered.sh to check out the right sha of the JS-SDK & EW, if they were given one + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} + ELEMENT_WEB_GITHUB_BASE_REF: ${{ inputs.element-web-sha }} + run: | + scripts/ci/layered.sh + JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD) + REACT_SHA=$(git rev-parse --short=12 HEAD) + VECTOR_SHA=$(git -C element-web rev-parse --short=12 HEAD) + echo "VERSION=$VECTOR_SHA-react-$REACT_SHA-js-$JSSDK_SHA" >> $GITHUB_OUTPUT + + - name: Copy config + run: cp element.io/develop/config.json config.json + working-directory: ./element-web + + - name: Build + env: + CI_PACKAGE: true + VERSION: "${{ steps.layered_build.outputs.VERSION }}" + run: | + yarn build + echo $VERSION > webapp/version + working-directory: ./element-web + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: webapp + path: element-web/webapp + retention-days: 1 + + playwright: + name: "Run Tests ${{ matrix.runner }}/${{ strategy.job-total }}" + needs: build + if: inputs.skip != true + runs-on: ubuntu-latest + permissions: + actions: read + issues: read + pull-requests: read + strategy: + fail-fast: false + matrix: + # Run multiple instances in parallel to speed up the tests + runner: [1, 2, 3, 4, 5, 6, 7, 8] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + path: matrix-react-sdk + repository: ${{ inputs.react-sdk-repository || github.repository }} + + - name: 📥 Download artifact + uses: actions/download-artifact@v4 + with: + name: webapp + path: webapp + + - uses: actions/setup-node@v4 + with: + cache: "yarn" + cache-dependency-path: matrix-react-sdk/yarn.lock + + - name: Install dependencies + working-directory: matrix-react-sdk + run: yarn install --frozen-lockfile + + - name: Get installed Playwright version + 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@v4 + id: playwright-cache + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: matrix-react-sdk + run: yarn playwright install --with-deps + + - name: Run Playwright tests + uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a + with: + run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }} + working-directory: matrix-react-sdk + + - name: Upload blob report to GitHub Actions Artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: all-blob-reports-${{ matrix.runner }} + path: matrix-react-sdk/blob-report + retention-days: 1 + + complete: + name: end-to-end-tests + needs: playwright + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + if: inputs.skip != true + with: + persist-credentials: false + repository: ${{ inputs.react-sdk-repository || github.repository }} + + - uses: actions/setup-node@v4 + if: inputs.skip != true + with: + cache: "yarn" + + - name: Install dependencies + if: inputs.skip != true + run: yarn install --frozen-lockfile + + - name: Download blob reports from GitHub Actions Artifacts + if: inputs.skip != true + uses: actions/download-artifact@v4 + with: + pattern: all-blob-reports-* + path: all-blob-reports + merge-multiple: true + + - name: Merge into HTML Report + if: inputs.skip != true + run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts ./all-blob-reports + env: + # Only pass creds to the flaky-reporter on main branch runs + GITHUB_TOKEN: ${{ github.event.workflow_run.head_branch == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }} + + - name: Upload HTML report + if: inputs.skip != true + uses: actions/upload-artifact@v4 + with: + name: html-report + path: playwright-report + retention-days: 14 + + - if: needs.playwright.result != 'skipped' && needs.playwright.result != 'success' + run: exit 1 diff --git a/.github/workflows/localazy_download.yaml b/.github/workflows/localazy_download.yaml new file mode 100644 index 00000000000..a880c3b2e40 --- /dev/null +++ b/.github/workflows/localazy_download.yaml @@ -0,0 +1,10 @@ +name: Localazy Download +on: + workflow_dispatch: {} + schedule: + - cron: "0 6 * * 1,3,5" # Every Monday, Wednesday and Friday at 6am UTC +jobs: + download: + uses: matrix-org/matrix-web-i18n/.github/workflows/localazy_download.yaml@main + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/localazy_upload.yaml b/.github/workflows/localazy_upload.yaml new file mode 100644 index 00000000000..9ba79800dbd --- /dev/null +++ b/.github/workflows/localazy_upload.yaml @@ -0,0 +1,11 @@ +name: Localazy Upload +on: + push: + branches: [develop] + paths: + - "src/i18n/strings/en_EN.json" +jobs: + upload: + uses: matrix-org/matrix-web-i18n/.github/workflows/localazy_upload.yaml@main + secrets: + LOCALAZY_WRITE_KEY: ${{ secrets.LOCALAZY_WRITE_KEY }} diff --git a/.github/workflows/playwright-image-updates.yaml b/.github/workflows/playwright-image-updates.yaml new file mode 100644 index 00000000000..15bea28e0f9 --- /dev/null +++ b/.github/workflows/playwright-image-updates.yaml @@ -0,0 +1,45 @@ +name: Update Playwright docker images +on: + workflow_dispatch: {} + schedule: + - cron: "0 6 * * *" # Every day at 6am UTC +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Update matrixdotorg/synapse image + run: | + docker pull "$IMAGE" + INSPECT=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE") + DIGEST=${INSPECT#*@} + sed -i "s/const DOCKER_TAG.*/const DOCKER_TAG = \"develop@$DIGEST\";/" playwright/plugins/homeserver/synapse/index.ts + env: + IMAGE: matrixdotorg/synapse:develop + + - name: Create Pull Request + id: cpr + uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5 + with: + token: ${{ secrets.ELEMENT_BOT_TOKEN }} + branch: actions/playwright-image-updates + delete-branch: true + title: Playwright Docker image updates + labels: | + T-Task + + - name: Enable automerge + run: gh pr merge --merge --auto "$PR_NUMBER" + if: steps.cpr.outputs.pull-request-operation == 'created' + env: + GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} + + - name: Enable autoapprove + run: | + gh pr review --approve "$PR_NUMBER" + if: steps.cpr.outputs.pull-request-operation == 'created' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 00000000000..d8afa80a9f9 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,9 @@ +name: Release Drafter +on: + push: + branches: [staging] + workflow_dispatch: {} +concurrency: ${{ github.workflow }} +jobs: + draft: + uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop diff --git a/.github/workflows/release-gitflow.yml b/.github/workflows/release-gitflow.yml new file mode 100644 index 00000000000..b515bb4cc18 --- /dev/null +++ b/.github/workflows/release-gitflow.yml @@ -0,0 +1,13 @@ +# Gitflow merge-back master->develop +name: Merge master -> develop +on: + push: + branches: [master] +concurrency: ${{ github.repository }}-${{ github.workflow }} +jobs: + merge: + uses: matrix-org/matrix-js-sdk/.github/workflows/release-gitflow.yml@develop + secrets: inherit + with: + dependencies: | + matrix-js-sdk diff --git a/.gitignore b/.gitignore index 489b5ccb397..3137cd555b1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,12 +19,3 @@ package-lock.json .vscode .vscode/ - -/cypress/videos -/cypress/downloads -/cypress/screenshots -/cypress/synapselogs -/cypress/dendritelogs -# These could have files in them but don't currently -# Cypress will still auto-create them though... -/cypress/performance diff --git a/.node-version b/.node-version index b6a7d89c68e..209e3ef4b62 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -16 +20 diff --git a/.percy.yml b/.percy.yml deleted file mode 100644 index 78238924233..00000000000 --- 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 072361a37de..00556f1c269 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 26fc56b7c03..ea499883aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,736 @@ +Changes in [3.101.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.101.0) (2024-06-18) +======================================================================================================= +## ✨ Features + +* Change avatar setting component to use a menu ([#12585](https://github.com/matrix-org/matrix-react-sdk/pull/12585)). Contributed by @dbkr. +* New user profile UI in User Settings ([#12548](https://github.com/matrix-org/matrix-react-sdk/pull/12548)). Contributed by @dbkr. +* MSC4108 support OIDC QR code login ([#12370](https://github.com/matrix-org/matrix-react-sdk/pull/12370)). Contributed by @t3chguy. + +## 🐛 Bug Fixes + +* [Backport staging] Fix image upload preview size ([#12612](https://github.com/matrix-org/matrix-react-sdk/pull/12612)). Contributed by @RiotRobot. +* Fix roving tab index crash `compareDocumentPosition` ([#12594](https://github.com/matrix-org/matrix-react-sdk/pull/12594)). Contributed by @t3chguy. +* Keep dialog glass border on narrow screens ([#12591](https://github.com/matrix-org/matrix-react-sdk/pull/12591)). Contributed by @dbkr. +* Add missing a11y label to dismiss onboarding button in room list ([#12587](https://github.com/matrix-org/matrix-react-sdk/pull/12587)). Contributed by @t3chguy. +* Add hover / active state on avatar setting upload button ([#12590](https://github.com/matrix-org/matrix-react-sdk/pull/12590)). Contributed by @dbkr. +* Fix EditInPlace button styles ([#12589](https://github.com/matrix-org/matrix-react-sdk/pull/12589)). Contributed by @dbkr. +* Fix incorrect assumptions about required fields in /search response ([#12575](https://github.com/matrix-org/matrix-react-sdk/pull/12575)). Contributed by @t3chguy. +* Fix display of no avatar in avatar setting controls ([#12558](https://github.com/matrix-org/matrix-react-sdk/pull/12558)). Contributed by @dbkr. +* Element-R: pass pickleKey in as raw key for indexeddb encryption ([#12543](https://github.com/matrix-org/matrix-react-sdk/pull/12543)). Contributed by @richvdh. + + +Changes in [3.100.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.100.0) (2024-06-04) +======================================================================================================= +## ✨ Features + +* Tooltip: Improve accessibility for context menus ([#12462](https://github.com/matrix-org/matrix-react-sdk/pull/12462)). Contributed by @florianduros. +* Tooltip: Improve accessibility of space panel ([#12525](https://github.com/matrix-org/matrix-react-sdk/pull/12525)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Close the release announcement when a dialog is opened ([#12559](https://github.com/matrix-org/matrix-react-sdk/pull/12559)). Contributed by @florianduros. +* Tooltip: close field tooltip when ESC is pressed ([#12553](https://github.com/matrix-org/matrix-react-sdk/pull/12553)). Contributed by @florianduros. +* Fix tabbedview breakpoint width ([#12556](https://github.com/matrix-org/matrix-react-sdk/pull/12556)). Contributed by @dbkr. +* Fix E2E icon display in room header ([#12545](https://github.com/matrix-org/matrix-react-sdk/pull/12545)). Contributed by @florianduros. +* Tooltip: Improve placement for space settings ([#12541](https://github.com/matrix-org/matrix-react-sdk/pull/12541)). Contributed by @florianduros. +* Fix deformed avatar in a call in a narrow timeline ([#12538](https://github.com/matrix-org/matrix-react-sdk/pull/12538)). Contributed by @florianduros. +* Shown own sent state indicator even when showReadReceipts is disabled ([#12540](https://github.com/matrix-org/matrix-react-sdk/pull/12540)). Contributed by @t3chguy. +* Ensure we do not fire the verification mismatch modal multiple times ([#12526](https://github.com/matrix-org/matrix-react-sdk/pull/12526)). Contributed by @t3chguy. +* Fix avatar in chat export ([#12537](https://github.com/matrix-org/matrix-react-sdk/pull/12537)). Contributed by @florianduros. +* Use `*` for italics as it doesn't break when used mid-word ([#12523](https://github.com/matrix-org/matrix-react-sdk/pull/12523)). Contributed by @t3chguy. + + +Changes in [3.99.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.99.0) (2024-05-07) +===================================================================================================== +## ✨ Features + +* Use a different error message for UTDs when you weren't in the room. ([#12453](https://github.com/matrix-org/matrix-react-sdk/pull/12453)). Contributed by @uhoreg. +* Take the Threads Activity Centre out of labs ([#12439](https://github.com/matrix-org/matrix-react-sdk/pull/12439)). Contributed by @dbkr. +* Expected UTDs: use a different message for UTDs sent before login ([#12391](https://github.com/matrix-org/matrix-react-sdk/pull/12391)). Contributed by @richvdh. +* Add `Tooltip` to `AccessibleButton` ([#12443](https://github.com/matrix-org/matrix-react-sdk/pull/12443)). Contributed by @florianduros. +* Add analytics to activity toggles ([#12418](https://github.com/matrix-org/matrix-react-sdk/pull/12418)). Contributed by @dbkr. +* Decrypt events in reverse order without copying the array ([#12445](https://github.com/matrix-org/matrix-react-sdk/pull/12445)). Contributed by @Johennes. +* Use new compound tooltip ([#12416](https://github.com/matrix-org/matrix-react-sdk/pull/12416)). Contributed by @florianduros. +* Expected UTDs: report a different Posthog code ([#12389](https://github.com/matrix-org/matrix-react-sdk/pull/12389)). Contributed by @richvdh. + +## 🐛 Bug Fixes + +* TAC: Fix accessibility issue when the Release announcement is displayed ([#12484](https://github.com/matrix-org/matrix-react-sdk/pull/12484)). Contributed by @RiotRobot. +* TAC: Close Release Announcement when TAC button is clicked ([#12485](https://github.com/matrix-org/matrix-react-sdk/pull/12485)). Contributed by @florianduros. +* MenuItem: fix caption usage ([#12455](https://github.com/matrix-org/matrix-react-sdk/pull/12455)). Contributed by @florianduros. +* Show the local echo in previews ([#12451](https://github.com/matrix-org/matrix-react-sdk/pull/12451)). Contributed by @langleyd. +* Fixed the drag and drop of X #27186 ([#12450](https://github.com/matrix-org/matrix-react-sdk/pull/12450)). Contributed by @asimdelvi. +* Move the TAC to above the button ([#12438](https://github.com/matrix-org/matrix-react-sdk/pull/12438)). Contributed by @dbkr. +* Use the same logic in previews as the timeline to hide events that should be hidden ([#12434](https://github.com/matrix-org/matrix-react-sdk/pull/12434)). Contributed by @langleyd. +* Fix selector so maths support doesn't mangle divs ([#12433](https://github.com/matrix-org/matrix-react-sdk/pull/12433)). Contributed by @uhoreg. + + +Changes in [3.98.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.98.0) (2024-04-23) +===================================================================================================== +## ✨ Features + +* Make empty state copy for TAC depend on the value of the setting ([#12419](https://github.com/matrix-org/matrix-react-sdk/pull/12419)). Contributed by @dbkr. +* Linkify User Interactive Authentication errors ([#12271](https://github.com/matrix-org/matrix-react-sdk/pull/12271)). Contributed by @t3chguy. +* Add support for device dehydration v2 ([#12316](https://github.com/matrix-org/matrix-react-sdk/pull/12316)). Contributed by @uhoreg. +* Replace `SecurityCustomisations` with `CryptoSetupExtension` ([#12342](https://github.com/matrix-org/matrix-react-sdk/pull/12342)). Contributed by @thoraj. +* Add activity toggle for TAC ([#12413](https://github.com/matrix-org/matrix-react-sdk/pull/12413)). Contributed by @dbkr. +* Humanize spell check language labels ([#12409](https://github.com/matrix-org/matrix-react-sdk/pull/12409)). Contributed by @t3chguy. +* Call Guest Access, give user the option to change the acces level so they can generate a call link. ([#12401](https://github.com/matrix-org/matrix-react-sdk/pull/12401)). Contributed by @toger5. +* TAC: Release Announcement ([#12380](https://github.com/matrix-org/matrix-react-sdk/pull/12380)). Contributed by @florianduros. +* Show the call and share button if the user can create a guest link. ([#12385](https://github.com/matrix-org/matrix-react-sdk/pull/12385)). Contributed by @toger5. +* Add analytics for mark all threads unread ([#12384](https://github.com/matrix-org/matrix-react-sdk/pull/12384)). Contributed by @dbkr. +* Add `EventType.RoomEncryption` to the auto approve capabilities of Element Call widgets ([#12386](https://github.com/matrix-org/matrix-react-sdk/pull/12386)). Contributed by @toger5. + +## 🐛 Bug Fixes + +* Fix link modal not shown after access upgrade ([#12411](https://github.com/matrix-org/matrix-react-sdk/pull/12411)). Contributed by @toger5. +* Fix thread navigation in timeline ([#12412](https://github.com/matrix-org/matrix-react-sdk/pull/12412)). Contributed by @florianduros. +* Fix inability to join a `knock` room via space hierarchy view ([#12404](https://github.com/matrix-org/matrix-react-sdk/pull/12404)). Contributed by @t3chguy. +* Focus the thread panel when clicking on an item in the TAC ([#12410](https://github.com/matrix-org/matrix-react-sdk/pull/12410)). Contributed by @dbkr. +* Fix space hierarchy tile busy state being stuck after join error ([#12405](https://github.com/matrix-org/matrix-react-sdk/pull/12405)). Contributed by @t3chguy. +* Fix room topic in-app links not being handled correctly on topic dialog ([#12406](https://github.com/matrix-org/matrix-react-sdk/pull/12406)). Contributed by @t3chguy. + + +Changes in [3.97.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.97.0) (2024-04-09) +===================================================================================================== +## ✨ Features + +* Mark all threads as read button ([#12378](https://github.com/matrix-org/matrix-react-sdk/pull/12378)). Contributed by @dbkr. +* Video call meta space ([#12297](https://github.com/matrix-org/matrix-react-sdk/pull/12297)). Contributed by @toger5. +* Add leave room warning for last admin ([#9452](https://github.com/matrix-org/matrix-react-sdk/pull/9452)). Contributed by @Arnei. +* Iterate styles around Link new device via QR ([#12356](https://github.com/matrix-org/matrix-react-sdk/pull/12356)). Contributed by @t3chguy. +* Improve code-splitting of highlight.js and maplibre-gs libs ([#12349](https://github.com/matrix-org/matrix-react-sdk/pull/12349)). Contributed by @t3chguy. +* Use data-mx-color for rainbows ([#12325](https://github.com/matrix-org/matrix-react-sdk/pull/12325)). Contributed by @tulir. + +## 🐛 Bug Fixes + +* Fix external guest access url for unencrypted rooms ([#12345](https://github.com/matrix-org/matrix-react-sdk/pull/12345)). Contributed by @toger5. +* Fix video rooms not showing share link button ([#12374](https://github.com/matrix-org/matrix-react-sdk/pull/12374)). Contributed by @toger5. +* Fix space topic jumping on hover/focus ([#12377](https://github.com/matrix-org/matrix-react-sdk/pull/12377)). Contributed by @t3chguy. +* Allow popping out a Jitsi widget to respect Desktop `web_base_url` config ([#12376](https://github.com/matrix-org/matrix-react-sdk/pull/12376)). Contributed by @t3chguy. +* Remove the Lazy Loading `InvalidStoreError` Dialogs ([#12358](https://github.com/matrix-org/matrix-react-sdk/pull/12358)). Contributed by @langleyd. +* Improve readability of badges and pills ([#12360](https://github.com/matrix-org/matrix-react-sdk/pull/12360)). Contributed by @robintown. + + +Changes in [3.96.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.96.1) (2024-03-28) +===================================================================================================== +## 🐛 Bug Fixes + +* Revert "Make EC widget theme reactive - Update widget url when the theme changes" ([#12383](https://github.com/matrix-org/matrix-react-sdk/pull/12383)) in order to fix widgets that require authentication. + + +Changes in [3.96.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.96.0) (2024-03-26) +===================================================================================================== +## ✨ Features + +* Change user permission by using a new apply button ([#12346](https://github.com/matrix-org/matrix-react-sdk/pull/12346)). Contributed by @florianduros. +* Mark as Unread ([#12254](https://github.com/matrix-org/matrix-react-sdk/pull/12254)). Contributed by @dbkr. +* Refine the colors of some more components ([#12343](https://github.com/matrix-org/matrix-react-sdk/pull/12343)). Contributed by @robintown. +* TAC: Order rooms by most recent after notification level ([#12329](https://github.com/matrix-org/matrix-react-sdk/pull/12329)). Contributed by @florianduros. +* Make EC widget theme reactive - Update widget url when the theme changes ([#12295](https://github.com/matrix-org/matrix-react-sdk/pull/12295)). Contributed by @toger5. +* Refine styles of menus, toasts, popovers, and modals ([#12332](https://github.com/matrix-org/matrix-react-sdk/pull/12332)). Contributed by @robintown. +* Element Call: fix widget shown while its still loading (`waitForIframeLoad=false`) ([#12292](https://github.com/matrix-org/matrix-react-sdk/pull/12292)). Contributed by @toger5. +* Improve Forward Dialog a11y by switching to roving tab index interactions ([#12306](https://github.com/matrix-org/matrix-react-sdk/pull/12306)). Contributed by @t3chguy. +* Call guest access link creation to join calls as a non registered user via the EC SPA ([#12259](https://github.com/matrix-org/matrix-react-sdk/pull/12259)). Contributed by @toger5. +* Use `strong` element to semantically denote visually emphasised content ([#12320](https://github.com/matrix-org/matrix-react-sdk/pull/12320)). Contributed by @t3chguy. +* Handle up/down arrow keys as well as left/right for horizontal toolbars for improved a11y ([#12305](https://github.com/matrix-org/matrix-react-sdk/pull/12305)). Contributed by @t3chguy. + +## 🐛 Bug Fixes + +* [Backport staging] Remove the glass border from modal spinners ([#12369](https://github.com/matrix-org/matrix-react-sdk/pull/12369)). Contributed by @RiotRobot. +* Fix incorrect check for private read receipt support ([#12348](https://github.com/matrix-org/matrix-react-sdk/pull/12348)). Contributed by @tulir. +* TAC: Fix hover state when expanded ([#12337](https://github.com/matrix-org/matrix-react-sdk/pull/12337)). Contributed by @florianduros. +* Fix the image view ([#12341](https://github.com/matrix-org/matrix-react-sdk/pull/12341)). Contributed by @robintown. +* Use correct push rule to evaluate room-wide mentions ([#12318](https://github.com/matrix-org/matrix-react-sdk/pull/12318)). Contributed by @t3chguy. +* Reset power selector on API failure to prevent state mismatch ([#12319](https://github.com/matrix-org/matrix-react-sdk/pull/12319)). Contributed by @t3chguy. +* Fix spotlight opening in TAC ([#12315](https://github.com/matrix-org/matrix-react-sdk/pull/12315)). Contributed by @florianduros. + + +Changes in [3.95.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.95.0) (2024-03-14) +===================================================================================================== +## 🐛 Bug Fixes + +* Update `@vector-im/compound-design-tokens` in package.json ([#12340](https://github.com/matrix-org/matrix-react-sdk/pull/12340)). + +Changes in [3.94.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.94.0) (2024-03-12) +===================================================================================================== +## ✨ Features + +* Refine styles of controls to match Compound ([#12299](https://github.com/matrix-org/matrix-react-sdk/pull/12299)). Contributed by @robintown. +* Hide the archived section ([#12286](https://github.com/matrix-org/matrix-react-sdk/pull/12286)). Contributed by @dbkr. +* Add theme data to EC widget Url ([#12279](https://github.com/matrix-org/matrix-react-sdk/pull/12279)). Contributed by @toger5. +* Update MSC2965 OIDC Discovery implementation ([#12245](https://github.com/matrix-org/matrix-react-sdk/pull/12245)). Contributed by @t3chguy. +* Use green dot for activity notification in `LegacyRoomHeader` ([#12270](https://github.com/matrix-org/matrix-react-sdk/pull/12270)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Fix requests for senders to submit auto-rageshakes ([#12304](https://github.com/matrix-org/matrix-react-sdk/pull/12304)). Contributed by @richvdh. +* fix automatic DM avatar with functional members ([#12157](https://github.com/matrix-org/matrix-react-sdk/pull/12157)). Contributed by @HarHarLinks. +* Feeds event with relation to unknown to the widget ([#12283](https://github.com/matrix-org/matrix-react-sdk/pull/12283)). Contributed by @maheichyk. +* Fix TAC opening with keyboard ([#12285](https://github.com/matrix-org/matrix-react-sdk/pull/12285)). Contributed by @florianduros. +* Allow screenshot update docker to run multiple test files ([#12291](https://github.com/matrix-org/matrix-react-sdk/pull/12291)). Contributed by @dbkr. +* Fix alignment of user menu avatar ([#12289](https://github.com/matrix-org/matrix-react-sdk/pull/12289)). Contributed by @dbkr. +* Fix buttons of widget in a room ([#12288](https://github.com/matrix-org/matrix-react-sdk/pull/12288)). Contributed by @florianduros. +* ModuleAPI: `overwrite_login` action was not stopping the existing client resulting in the action failing with rust-sdk ([#12272](https://github.com/matrix-org/matrix-react-sdk/pull/12272)). Contributed by @BillCarsonFr. + + +Changes in [3.93.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.93.0) (2024-02-27) +===================================================================================================== +## 🦖 Deprecations + +* Enable custom themes to theme Compound ([#12240](https://github.com/matrix-org/matrix-react-sdk/pull/12240)). Contributed by @robintown. +* Remove welcome bot `welcome_user_id` support ([#12153](https://github.com/matrix-org/matrix-react-sdk/pull/12153)). Contributed by @t3chguy. + +## ✨ Features + +* Ignore activity in TAC ([#12269](https://github.com/matrix-org/matrix-react-sdk/pull/12269)). Contributed by @florianduros. +* Use browser's font size instead of hardcoded `16px` as root font size ([#12246](https://github.com/matrix-org/matrix-react-sdk/pull/12246)). Contributed by @florianduros. +* Revert "Use Compound primary colors for most actions" ([#12264](https://github.com/matrix-org/matrix-react-sdk/pull/12264)). Contributed by @florianduros. +* Revert "Refine menu, toast, and popover colors" ([#12263](https://github.com/matrix-org/matrix-react-sdk/pull/12263)). Contributed by @florianduros. +* Fix Native OIDC for Element Desktop ([#12253](https://github.com/matrix-org/matrix-react-sdk/pull/12253)). Contributed by @t3chguy. +* Improve client metadata used for OIDC dynamic registration ([#12257](https://github.com/matrix-org/matrix-react-sdk/pull/12257)). Contributed by @t3chguy. +* Refine menu, toast, and popover colors ([#12247](https://github.com/matrix-org/matrix-react-sdk/pull/12247)). Contributed by @robintown. +* Call the AsJson forms of import and exportRoomKeys ([#12233](https://github.com/matrix-org/matrix-react-sdk/pull/12233)). Contributed by @andybalaam. +* Use Compound primary colors for most actions ([#12241](https://github.com/matrix-org/matrix-react-sdk/pull/12241)). Contributed by @robintown. +* Enable redirected media by default ([#12142](https://github.com/matrix-org/matrix-react-sdk/pull/12142)). Contributed by @turt2live. +* Reduce TAC width by `16px` ([#12239](https://github.com/matrix-org/matrix-react-sdk/pull/12239)). Contributed by @florianduros. +* Pop out of Threads Activity Centre ([#12136](https://github.com/matrix-org/matrix-react-sdk/pull/12136)). Contributed by @florianduros. +* Use new semantic tokens for username colors ([#12209](https://github.com/matrix-org/matrix-react-sdk/pull/12209)). Contributed by @robintown. + +## 🐛 Bug Fixes + +* [Backport staging] Fix spurious session corruption error ([#12287](https://github.com/matrix-org/matrix-react-sdk/pull/12287)). Contributed by @RiotRobot. +* Fix the space panel getting bigger when gaining a scroll bar ([#12267](https://github.com/matrix-org/matrix-react-sdk/pull/12267)). Contributed by @dbkr. +* Fix gradients spacings on the space panel ([#12262](https://github.com/matrix-org/matrix-react-sdk/pull/12262)). Contributed by @dbkr. +* Remove hardcoded `Element` in tac labs description ([#12266](https://github.com/matrix-org/matrix-react-sdk/pull/12266)). Contributed by @florianduros. +* Fix branding in "migrating crypto" message ([#12265](https://github.com/matrix-org/matrix-react-sdk/pull/12265)). Contributed by @richvdh. +* Use h1 as first heading in dialogs ([#12250](https://github.com/matrix-org/matrix-react-sdk/pull/12250)). Contributed by @dbkr. +* Fix forced lowercase username in login/registration flows ([#9329](https://github.com/matrix-org/matrix-react-sdk/pull/9329)). Contributed by @vrifox. +* Update the TAC indicator on event decryption ([#12243](https://github.com/matrix-org/matrix-react-sdk/pull/12243)). Contributed by @dbkr. +* Fix OIDC delegated auth account url check ([#12242](https://github.com/matrix-org/matrix-react-sdk/pull/12242)). Contributed by @t3chguy. +* New Header edgecase fixes: Close lobby button not shown, disable join button in various places, more... ([#12235](https://github.com/matrix-org/matrix-react-sdk/pull/12235)). Contributed by @toger5. +* Fix TAC button alignment when expanded ([#12238](https://github.com/matrix-org/matrix-react-sdk/pull/12238)). Contributed by @florianduros. +* Fix tooltip behaviour in TAC ([#12236](https://github.com/matrix-org/matrix-react-sdk/pull/12236)). Contributed by @florianduros. + + +Changes in [3.92.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.92.0) (2024-02-13) +===================================================================================================== +## ✨ Features + +* Add Element call related functionality to new room header ([#12091](https://github.com/matrix-org/matrix-react-sdk/pull/12091)). Contributed by @toger5. +* Add labs flag for Threads Activity Centre ([#12137](https://github.com/matrix-org/matrix-react-sdk/pull/12137)). Contributed by @florianduros. +* Refactor element call lobby + skip lobby ([#12057](https://github.com/matrix-org/matrix-react-sdk/pull/12057)). Contributed by @toger5. +* Hide the "Message" button in the sidebar if the CreateRooms components should not be shown ([#9271](https://github.com/matrix-org/matrix-react-sdk/pull/9271)). Contributed by @dhenneke. +* Add notification dots to thread summary icons ([#12146](https://github.com/matrix-org/matrix-react-sdk/pull/12146)). Contributed by @dbkr. + +## 🐛 Bug Fixes + +* [Backport staging] Fix the StorageManger detecting a false positive consistency check when manually migrating to rust from labs ([#12230](https://github.com/matrix-org/matrix-react-sdk/pull/12230)). Contributed by @RiotRobot. +* Fix logout can take ages ([#12191](https://github.com/matrix-org/matrix-react-sdk/pull/12191)). Contributed by @BillCarsonFr. +* Fix `Mark all as read` in settings ([#12205](https://github.com/matrix-org/matrix-react-sdk/pull/12205)). Contributed by @florianduros. +* Fix default thread notification of the new RoomHeader ([#12194](https://github.com/matrix-org/matrix-react-sdk/pull/12194)). Contributed by @florianduros. +* Fix display of room notification debug info ([#12183](https://github.com/matrix-org/matrix-react-sdk/pull/12183)). Contributed by @dbkr. + + +Changes in [3.91.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.91.0) (2024-01-31) +===================================================================================================== +## ✨ Features + +* Expose apps/widgets ([#12071](https://github.com/matrix-org/matrix-react-sdk/pull/12071)). Contributed by @charlynguyen. +* Enable the rust-crypto labs button ([#12114](https://github.com/matrix-org/matrix-react-sdk/pull/12114)). Contributed by @richvdh. +* Show a progress bar while migrating from legacy crypto ([#12104](https://github.com/matrix-org/matrix-react-sdk/pull/12104)). Contributed by @richvdh. +* Update Twemoji to Jdecked v15.0.3 ([#12147](https://github.com/matrix-org/matrix-react-sdk/pull/12147)). Contributed by @t3chguy. +* Change Quick Settings icon ([#12141](https://github.com/matrix-org/matrix-react-sdk/pull/12141)). Contributed by @florianduros. +* Use Compound tooltips more widely ([#12128](https://github.com/matrix-org/matrix-react-sdk/pull/12128)). Contributed by @t3chguy. + +## 🐛 Bug Fixes + +* Fix OIDC bugs due to amnesiac stores forgetting OIDC issuer \& other data ([#12166](https://github.com/matrix-org/matrix-react-sdk/pull/12166)). Contributed by @t3chguy. +* Fix account management link for delegated auth OIDC setups ([#12144](https://github.com/matrix-org/matrix-react-sdk/pull/12144)). Contributed by @t3chguy. +* Fix Safari IME support ([#11016](https://github.com/matrix-org/matrix-react-sdk/pull/11016)). Contributed by @SuperKenVery. +* Fix Stickerpicker layout crossing multiple CSS stacking contexts ([#12127](https://github.com/matrix-org/matrix-react-sdk/pull/12127)). +* Fix Stickerpicker layout crossing multiple CSS stacking contexts ([#12126](https://github.com/matrix-org/matrix-react-sdk/pull/12126)). Contributed by @t3chguy. +* Fix 1F97A and 1F979 in Twemoji COLR font ([#12177](https://github.com/matrix-org/matrix-react-sdk/pull/12177)). + + +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 + +* Remove Quote from MessageContextMenu as it is unsupported by WYSIWYG ([#11914](https://github.com/matrix-org/matrix-react-sdk/pull/11914)). Contributed by @t3chguy. + +## ✨ Features + +* Always allow call.member events on new rooms ([#11948](https://github.com/matrix-org/matrix-react-sdk/pull/11948)). Contributed by @toger5. +* Right panel: view third party invite info without clearing history ([#11934](https://github.com/matrix-org/matrix-react-sdk/pull/11934)). Contributed by @kerryarchibald. +* Allow switching to system emoji font ([#11925](https://github.com/matrix-org/matrix-react-sdk/pull/11925)). Contributed by @t3chguy. +* Update open in other tab message ([#11916](https://github.com/matrix-org/matrix-react-sdk/pull/11916)). Contributed by @weeman1337. +* Add menu for legacy and element call in 1:1 rooms ([#11910](https://github.com/matrix-org/matrix-react-sdk/pull/11910)). Contributed by @toger5. +* Add ringing for matrixRTC ([#11870](https://github.com/matrix-org/matrix-react-sdk/pull/11870)). Contributed by @toger5. + +## 🐛 Bug Fixes + +* Keep device language when it has been previosuly set, after a successful delegated authentication flow that clears localStorage ([#11902](https://github.com/matrix-org/matrix-react-sdk/pull/11902)). Contributed by @mgcm. +* Fix misunderstanding of functional members ([#11918](https://github.com/matrix-org/matrix-react-sdk/pull/11918)). Contributed by @toger5. +* Fix: Video Room Chat Header Button Removed ([#11911](https://github.com/matrix-org/matrix-react-sdk/pull/11911)). Contributed by @kerryarchibald. +* Fix "not attempting encryption" warning ([#11899](https://github.com/matrix-org/matrix-react-sdk/pull/11899)). Contributed by @richvdh. + + +Changes in [3.85.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.85.0) (2023-11-21) +===================================================================================================== +## ✨ Features + +* Update room summary card header ([#11823](https://github.com/matrix-org/matrix-react-sdk/pull/11823)). Contributed by @germain-gg. +* Add feature flag for disabling encryption in Element Call ([#11837](https://github.com/matrix-org/matrix-react-sdk/pull/11837)). Contributed by @toger5. +* Adapt the rendering of extra icons in the room header ([#11835](https://github.com/matrix-org/matrix-react-sdk/pull/11835)). Contributed by @charlynguyen. +* Implement new unreachable state and fix broken string ref ([#11748](https://github.com/matrix-org/matrix-react-sdk/pull/11748)). Contributed by @MidhunSureshR. +* Allow adding extra icons to the room header ([#11799](https://github.com/matrix-org/matrix-react-sdk/pull/11799)). Contributed by @charlynguyen. + +## 🐛 Bug Fixes + +* Room header: do not collapse avatar or facepile ([#11866](https://github.com/matrix-org/matrix-react-sdk/pull/11866)). Contributed by @kerryarchibald. +* New right panel: fix button alignment in memberlist ([#11861](https://github.com/matrix-org/matrix-react-sdk/pull/11861)). Contributed by @kerryarchibald. +* Use the correct video call icon variant ([#11859](https://github.com/matrix-org/matrix-react-sdk/pull/11859)). Contributed by @robintown. +* fix broken warning icon ([#11862](https://github.com/matrix-org/matrix-react-sdk/pull/11862)). Contributed by @ara4n. +* Fix rightpanel hiding scrollbar ([#11831](https://github.com/matrix-org/matrix-react-sdk/pull/11831)). Contributed by @kerryarchibald. +* Switch to updating presence via /sync calls instead of PUT /presence ([#11824](https://github.com/matrix-org/matrix-react-sdk/pull/11824)). Contributed by @t3chguy. + + +Changes in [3.84.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.84.1) (2023-11-13) +===================================================================================================== + +## 🐛 Bug Fixes + * Ensure `setUserCreator` is called when a store is assigned ([\#3867](https://github.com/matrix-org/matrix-js-sdk/pull/3867)). Fixes vector-im/element-web#26520. Contributed by @MidhunSureshR. + +Changes in [3.84.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.84.0) (2023-11-07) +===================================================================================================== + +## ✨ Features + * Knock on a ask-to-join room if a module wants to join the room when navigating to a room ([\#11787](https://github.com/matrix-org/matrix-react-sdk/pull/11787)). Contributed by @dhenneke. + * Element-R: Include crypto info in sentry ([\#11798](https://github.com/matrix-org/matrix-react-sdk/pull/11798)). Contributed by @florianduros. + * Element-R: Include crypto info in rageshake ([\#11797](https://github.com/matrix-org/matrix-react-sdk/pull/11797)). Contributed by @florianduros. + * Element-R: Add current version of the rust-sdk and vodozemac ([\#11785](https://github.com/matrix-org/matrix-react-sdk/pull/11785)). Contributed by @florianduros. + * Fix unfederated invite dialog ([\#9618](https://github.com/matrix-org/matrix-react-sdk/pull/9618)). Fixes vector-im/element-meta#1466 and vector-im/element-web#22102. Contributed by @owi92. + * New right panel visual language ([\#11664](https://github.com/matrix-org/matrix-react-sdk/pull/11664)). + * OIDC: add friendly errors ([\#11184](https://github.com/matrix-org/matrix-react-sdk/pull/11184)). Fixes vector-im/element-web#25665. Contributed by @kerryarchibald. + +## 🐛 Bug Fixes + * Fix rightpanel hiding scrollbar ([\#11831](https://github.com/matrix-org/matrix-react-sdk/pull/11831)). Contributed by @kerryarchibald. + * Fix multi-tab session lock on Firefox not being cleared ([\#11800](https://github.com/matrix-org/matrix-react-sdk/pull/11800)). Fixes vector-im/element-web#26165. Contributed by @ManuelHu. + * Deserialise spoilers back into slash command form ([\#11805](https://github.com/matrix-org/matrix-react-sdk/pull/11805)). Fixes vector-im/element-web#26344. + * Fix Incorrect message scaling for verification request ([\#11793](https://github.com/matrix-org/matrix-react-sdk/pull/11793)). Fixes vector-im/element-web#24304. Contributed by @capGoblin. + * Fix: Unable to restore a soft-logged-out session established via SSO ([\#11794](https://github.com/matrix-org/matrix-react-sdk/pull/11794)). Fixes vector-im/element-web#25957. Contributed by @kerryarchibald. + * Use configurable github issue links more consistently ([\#11796](https://github.com/matrix-org/matrix-react-sdk/pull/11796)). + * Fix io.element.late_event received_ts vs received_at ([\#11789](https://github.com/matrix-org/matrix-react-sdk/pull/11789)). + * Make invitation dialog scrollable when infos are too long ([\#11753](https://github.com/matrix-org/matrix-react-sdk/pull/11753)). Contributed by @nurjinjafar. + * Fix spoiler text-align ([\#11790](https://github.com/matrix-org/matrix-react-sdk/pull/11790)). Contributed by @ajbura. + * Fix: Right panel keeps showing chat when unmaximizing widget. ([\#11697](https://github.com/matrix-org/matrix-react-sdk/pull/11697)). Fixes vector-im/element-web#26265. Contributed by @manancodes. + * Fix margin of invite to room button ([\#11780](https://github.com/matrix-org/matrix-react-sdk/pull/11780)). Fixes vector-im/element-web#26410. + * Update base64 import ([\#11784](https://github.com/matrix-org/matrix-react-sdk/pull/11784)). + * Set max size for Element logo in search warning ([\#11779](https://github.com/matrix-org/matrix-react-sdk/pull/11779)). Fixes vector-im/element-web#26408. + * Fix: emoji size in room header topic, remove obsolete emoji style ([\#11757](https://github.com/matrix-org/matrix-react-sdk/pull/11757)). Fixes vector-im/element-web#26326. Contributed by @kerryarchibald. + * Fix: Bubble layout design is broken ([\#11763](https://github.com/matrix-org/matrix-react-sdk/pull/11763)). Fixes vector-im/element-web#25818. Contributed by @manancodes. + +Changes in [3.83.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.83.0) (2023-10-24) +===================================================================================================== + +## ✨ Features + * Iterate `io.element.late_event` decoration ([\#11760](https://github.com/matrix-org/matrix-react-sdk/pull/11760)). Fixes vector-im/element-web#26384. + * Render timeline separator for late event groups ([\#11739](https://github.com/matrix-org/matrix-react-sdk/pull/11739)). + * OIDC: revoke tokens on logout ([\#11718](https://github.com/matrix-org/matrix-react-sdk/pull/11718)). Fixes vector-im/element-web#25394. Contributed by @kerryarchibald. + * Show `io.element.late_event` in MessageTimestamp when known ([\#11733](https://github.com/matrix-org/matrix-react-sdk/pull/11733)). + * Show all labs flags if developerMode enabled ([\#11746](https://github.com/matrix-org/matrix-react-sdk/pull/11746)). Fixes vector-im/element-web#24571 and vector-im/element-web#8498. + * Use Compound tooltips on MessageTimestamp to improve UX of date time discovery ([\#11732](https://github.com/matrix-org/matrix-react-sdk/pull/11732)). Fixes vector-im/element-web#25913. + * Consolidate 4s passphrase input fields and use stable IDs ([\#11743](https://github.com/matrix-org/matrix-react-sdk/pull/11743)). Fixes vector-im/element-web#26228. + * Disable upgraderoom command without developer mode enabled ([\#11744](https://github.com/matrix-org/matrix-react-sdk/pull/11744)). Fixes vector-im/element-web#17620. + * Avoid rendering app download buttons if disabled in config ([\#11741](https://github.com/matrix-org/matrix-react-sdk/pull/11741)). Fixes vector-im/element-web#26309. + * OIDC: refresh tokens ([\#11699](https://github.com/matrix-org/matrix-react-sdk/pull/11699)). Fixes vector-im/element-web#25839. Contributed by @kerryarchibald. + * OIDC: register ([\#11727](https://github.com/matrix-org/matrix-react-sdk/pull/11727)). Fixes vector-im/element-web#25393. Contributed by @kerryarchibald. + * Use stable get_login_token and remove unstable MSC3882 support ([\#11001](https://github.com/matrix-org/matrix-react-sdk/pull/11001)). Contributed by @hughns. + +## 🐛 Bug Fixes + * Set max size for Element logo in search warning ([\#11779](https://github.com/matrix-org/matrix-react-sdk/pull/11779)). Fixes vector-im/element-web#26408. + * Avoid error when DMing oneself ([\#11754](https://github.com/matrix-org/matrix-react-sdk/pull/11754)). Fixes vector-im/element-web#7242. + * Fix: Message shield alignment is not right. ([\#11703](https://github.com/matrix-org/matrix-react-sdk/pull/11703)). Fixes vector-im/element-web#26142. Contributed by @manancodes. + * fix logging full event ([\#11755](https://github.com/matrix-org/matrix-react-sdk/pull/11755)). Fixes vector-im/element-web#26376. + * OIDC: use delegated auth account URL from `OidcClientStore` ([\#11723](https://github.com/matrix-org/matrix-react-sdk/pull/11723)). Fixes vector-im/element-web#26305. Contributed by @kerryarchibald. + * Fix: Members list shield alignment is not right. ([\#11700](https://github.com/matrix-org/matrix-react-sdk/pull/11700)). Fixes vector-im/element-web#26261. Contributed by @manancodes. + * Fix: HTML elements clickable area too wide. ([\#11666](https://github.com/matrix-org/matrix-react-sdk/pull/11666)). Fixes vector-im/element-web#25454. Contributed by @manancodes. + * Fix untranslated headings in the devtools dialog ([\#11734](https://github.com/matrix-org/matrix-react-sdk/pull/11734)). + * Fixes invite dialog alignment and pill color contrast ([\#11722](https://github.com/matrix-org/matrix-react-sdk/pull/11722)). Contributed by @gabrc52. + * Prevent select element in General settings overflowing in a room with very long room-id ([\#11597](https://github.com/matrix-org/matrix-react-sdk/pull/11597)). Contributed by @ABHIXIT2. + * Fix: Clicking on members pile does nothing. ([\#11657](https://github.com/matrix-org/matrix-react-sdk/pull/11657)). Fixes vector-im/element-web#26164. Contributed by @manancodes. + * Fix: Wierd shadow below room avatar in dark mode. ([\#11678](https://github.com/matrix-org/matrix-react-sdk/pull/11678)). Fixes vector-im/element-web#26153. Contributed by @manancodes. + * Fix start_sso / start_cas URLs failing to redirect to a authentication prompt ([\#11681](https://github.com/matrix-org/matrix-react-sdk/pull/11681)). Contributed by @Half-Shot. + +Changes in [3.82.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.82.0) (2023-10-10) +===================================================================================================== + +## ✨ Features + * Use .well-known to discover a default rendezvous server for use with Sign in with QR ([\#11655](https://github.com/matrix-org/matrix-react-sdk/pull/11655)). Contributed by @hughns. + * Message layout will update according to the selected style ([\#10170](https://github.com/matrix-org/matrix-react-sdk/pull/10170)). Fixes vector-im/element-web#21782. Contributed by @manancodes. + * Implement MSC4039: Add an MSC for a new Widget API action to upload files into the media repository ([\#11311](https://github.com/matrix-org/matrix-react-sdk/pull/11311)). Contributed by @dhenneke. + * Render space pills with square corners to match new avatar ([\#11632](https://github.com/matrix-org/matrix-react-sdk/pull/11632)). Fixes vector-im/element-web#26056. + * Linkify room topic ([\#11631](https://github.com/matrix-org/matrix-react-sdk/pull/11631)). Fixes vector-im/element-web#26185. + * Show knock rooms in the list ([\#11573](https://github.com/matrix-org/matrix-react-sdk/pull/11573)). Contributed by @maheichyk. + +## 🐛 Bug Fixes + * Fix: Avatar shrinks with long names ([\#11698](https://github.com/matrix-org/matrix-react-sdk/pull/11698)). Fixes vector-im/element-web#26252. Contributed by @manancodes. + * Update custom translations to support nested fields in structured JSON ([\#11685](https://github.com/matrix-org/matrix-react-sdk/pull/11685)). + * Fix: Edited message remove button is hard to reach. ([\#11674](https://github.com/matrix-org/matrix-react-sdk/pull/11674)). Fixes vector-im/element-web#24917. Contributed by @manancodes. + * Fix: Theme selector radio button not aligned in center with the text ([\#11676](https://github.com/matrix-org/matrix-react-sdk/pull/11676)). Fixes vector-im/element-web#25460. Contributed by @manancodes. + * Fix: Unread notification dot aligned ([\#11658](https://github.com/matrix-org/matrix-react-sdk/pull/11658)). Fixes vector-im/element-web#25285. Contributed by @manancodes. + * Fix: sync intentional mentions push rules with legacy rules ([\#11667](https://github.com/matrix-org/matrix-react-sdk/pull/11667)). Fixes vector-im/element-web#26227. Contributed by @kerryarchibald. + * Revert "Fix regression around FacePile with overflow (#11527)" ([\#11634](https://github.com/matrix-org/matrix-react-sdk/pull/11634)). Fixes vector-im/element-web#26209. + * Fix: Alignment Fixed ([\#11648](https://github.com/matrix-org/matrix-react-sdk/pull/11648)). Fixes vector-im/element-web#26169. Contributed by @manancodes. + * Fix: onFinished added which closes the menu ([\#11647](https://github.com/matrix-org/matrix-react-sdk/pull/11647)). Fixes vector-im/element-web#25556. Contributed by @manancodes. + * Don't start key backups when opening settings ([\#11640](https://github.com/matrix-org/matrix-react-sdk/pull/11640)). + * Fix add to space avatar text centering ([\#11643](https://github.com/matrix-org/matrix-react-sdk/pull/11643)). Fixes vector-im/element-web#26154. + * fix avatar styling in lightbox ([\#11641](https://github.com/matrix-org/matrix-react-sdk/pull/11641)). Fixes vector-im/element-web#26196. + +Changes in [3.81.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.81.1) (2023-09-29) +===================================================================================================== + +## 🐛 Bug Fixes + * Fix Emoji font on Safari 17 ([\#11673](https://github.com/matrix-org/matrix-react-sdk/pull/11673)). + +Changes in [3.81.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.81.0) (2023-09-26) +===================================================================================================== + +## ✨ Features + * Make video & voice call buttons pin conference widget if unpinned ([\#11576](https://github.com/matrix-org/matrix-react-sdk/pull/11576)). Fixes vector-im/customer-retainer#72. + * OIDC: persist refresh token ([\#11249](https://github.com/matrix-org/matrix-react-sdk/pull/11249)). Contributed by @kerryarchibald. + * ElementR: Cross user verification ([\#11364](https://github.com/matrix-org/matrix-react-sdk/pull/11364)). Fixes vector-im/element-web#25752. Contributed by @florianduros. + * Default intentional mentions ([\#11602](https://github.com/matrix-org/matrix-react-sdk/pull/11602)). + * Notify users about denied access on ask-to-join rooms ([\#11480](https://github.com/matrix-org/matrix-react-sdk/pull/11480)). Contributed by @nurjinjafar. + * Allow setting knock room directory visibility ([\#11529](https://github.com/matrix-org/matrix-react-sdk/pull/11529)). Contributed by @charlynguyen. + +## 🐛 Bug Fixes + * Revert "Fix regression around FacePile with overflow (#11527)" ([\#11634](https://github.com/matrix-org/matrix-react-sdk/pull/11634)). Fixes vector-im/element-web#26209. + * Escape placeholder before injecting it into the style ([\#11607](https://github.com/matrix-org/matrix-react-sdk/pull/11607)). + * Move ViewUser action callback to RoomView ([\#11495](https://github.com/matrix-org/matrix-react-sdk/pull/11495)). Fixes vector-im/element-web#26040. + * Fix room timeline search toggling behaviour edge case ([\#11605](https://github.com/matrix-org/matrix-react-sdk/pull/11605)). Fixes vector-im/element-web#26105. + * Avoid rendering view-message link in RoomKnocksBar unnecessarily ([\#11598](https://github.com/matrix-org/matrix-react-sdk/pull/11598)). Contributed by @charlynguyen. + * Use knock rooms sync to reflect the knock state ([\#11596](https://github.com/matrix-org/matrix-react-sdk/pull/11596)). Fixes vector-im/element-web#26043 and vector-im/element-web#26044. Contributed by @charlynguyen. + * Fix avatar in right panel not using the correct font ([\#11593](https://github.com/matrix-org/matrix-react-sdk/pull/11593)). Fixes vector-im/element-web#26061. Contributed by @MidhunSureshR. + * Add waits in Spotlight Cypress tests, hoping this unflakes them ([\#11590](https://github.com/matrix-org/matrix-react-sdk/pull/11590)). Fixes vector-im/element-web#26053, vector-im/element-web#26140 vector-im/element-web#26139 and vector-im/element-web#26138. Contributed by @andybalaam. + * Fix vertical alignment of default avatar font ([\#11582](https://github.com/matrix-org/matrix-react-sdk/pull/11582)). Fixes vector-im/element-web#26081. + * Fix avatars in public room & space search being flex shrunk ([\#11580](https://github.com/matrix-org/matrix-react-sdk/pull/11580)). Fixes vector-im/element-web#26133. + * Fix EventTile avatars being rendered with a size of 0 instead of hidden ([\#11558](https://github.com/matrix-org/matrix-react-sdk/pull/11558)). Fixes vector-im/element-web#26075. + +Changes in [3.80.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.80.1) (2023-09-13) +===================================================================================================== + +## 🐛 Bug Fixes + * Update Compound to fix Firefox-specific avatar regression ([\#11604](https://github.com/matrix-org/matrix-react-sdk/pull/11604)) + +Changes in [3.80.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.80.0) (2023-09-12) +===================================================================================================== + +## ✨ Features + * Allow creating public knock rooms ([\#11481](https://github.com/matrix-org/matrix-react-sdk/pull/11481)). Contributed by @charlynguyen. + * Render custom images in reactions according to MSC4027 ([\#11087](https://github.com/matrix-org/matrix-react-sdk/pull/11087)). Contributed by @sumnerevans. + * Introduce room knocks bar ([\#11475](https://github.com/matrix-org/matrix-react-sdk/pull/11475)). Contributed by @charlynguyen. + * Room header UI updates ([\#11507](https://github.com/matrix-org/matrix-react-sdk/pull/11507)). Fixes vector-im/element-web#25892. + * Remove green "verified" bar for encrypted events ([\#11496](https://github.com/matrix-org/matrix-react-sdk/pull/11496)). + * Update member count on room summary update ([\#11488](https://github.com/matrix-org/matrix-react-sdk/pull/11488)). + * Support for E2EE in Element Call ([\#11492](https://github.com/matrix-org/matrix-react-sdk/pull/11492)). + * Allow requesting to join knock rooms via spotlight ([\#11482](https://github.com/matrix-org/matrix-react-sdk/pull/11482)). Contributed by @charlynguyen. + * Lock out the first tab if Element is opened in a second tab. ([\#11425](https://github.com/matrix-org/matrix-react-sdk/pull/11425)). Fixes vector-im/element-web#25157. + * Change avatar to use Compound implementation ([\#11448](https://github.com/matrix-org/matrix-react-sdk/pull/11448)). + +## 🐛 Bug Fixes + * Fix vertical alignment of default avatar font ([\#11582](https://github.com/matrix-org/matrix-react-sdk/pull/11582)). Fixes vector-im/element-web#26081. + * Fix avatars in public room & space search being flex shrunk ([\#11580](https://github.com/matrix-org/matrix-react-sdk/pull/11580)). Fixes vector-im/element-web#26133. + * Fix EventTile avatars being rendered with a size of 0 instead of hidden ([\#11558](https://github.com/matrix-org/matrix-react-sdk/pull/11558)). Fixes vector-im/element-web#26075. + * Use RoomStateEvent.Update for knocks ([\#11516](https://github.com/matrix-org/matrix-react-sdk/pull/11516)). Contributed by @charlynguyen. + * Prevent event propagation when clicking icon buttons ([\#11515](https://github.com/matrix-org/matrix-react-sdk/pull/11515)). + * Only display RoomKnocksBar when feature flag is enabled ([\#11513](https://github.com/matrix-org/matrix-react-sdk/pull/11513)). Contributed by @andybalaam. + * Fix avatars of knock members for people tab of room settings ([\#11506](https://github.com/matrix-org/matrix-react-sdk/pull/11506)). Fixes vector-im/element-web#26083. Contributed by @charlynguyen. + * Fixes read receipt avatar offset ([\#11483](https://github.com/matrix-org/matrix-react-sdk/pull/11483)). Fixes vector-im/element-web#26067, vector-im/element-web#26064 vector-im/element-web#26059 and vector-im/element-web#26061. + * Fix avatar defects ([\#11473](https://github.com/matrix-org/matrix-react-sdk/pull/11473)). Fixes vector-im/element-web#26051 and vector-im/element-web#26046. + * Fix consistent avatar output for Percy ([\#11472](https://github.com/matrix-org/matrix-react-sdk/pull/11472)). Fixes vector-im/element-web#26049 and vector-im/element-web#26052. + * Fix colour of avatar and colour matching with username ([\#11470](https://github.com/matrix-org/matrix-react-sdk/pull/11470)). Fixes vector-im/element-web#26042. + * Fix incompatibility of Soft Logout with Element-R ([\#11468](https://github.com/matrix-org/matrix-react-sdk/pull/11468)). + * Fix instances of double translation and guard translation calls using typescript ([\#11443](https://github.com/matrix-org/matrix-react-sdk/pull/11443)). + +Changes in [3.79.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.79.0) (2023-08-29) +===================================================================================================== + +## ✨ Features + * Hide account deactivation for externally managed accounts ([\#11445](https://github.com/matrix-org/matrix-react-sdk/pull/11445)). Fixes vector-im/element-web#26022. Contributed by @kerryarchibald. + * OIDC: Redirect to delegated auth provider when signing out ([\#11432](https://github.com/matrix-org/matrix-react-sdk/pull/11432)). Fixes vector-im/element-web#26000. Contributed by @kerryarchibald. + * Disable 3pid fields in settings when `m.3pid_changes` capability is disabled ([\#11430](https://github.com/matrix-org/matrix-react-sdk/pull/11430)). Fixes vector-im/element-web#25995. Contributed by @kerryarchibald. + * OIDC: disable multi session signout for OIDC-aware servers in session manager ([\#11431](https://github.com/matrix-org/matrix-react-sdk/pull/11431)). Contributed by @kerryarchibald. + * Implement updated open dialog method of the Module API ([\#11395](https://github.com/matrix-org/matrix-react-sdk/pull/11395)). Contributed by @dhenneke. + * Polish & delabs `Exploring public spaces` feature ([\#11423](https://github.com/matrix-org/matrix-react-sdk/pull/11423)). + * Treat lists with a single empty item as plain text, not Markdown. ([\#6833](https://github.com/matrix-org/matrix-react-sdk/pull/6833)). Fixes vector-im/element-meta#1265. + * Allow managing room knocks ([\#11404](https://github.com/matrix-org/matrix-react-sdk/pull/11404)). Contributed by @charlynguyen. + * Pin the action buttons to the bottom of the scrollable dialogs ([\#11407](https://github.com/matrix-org/matrix-react-sdk/pull/11407)). Contributed by @dhenneke. + * Support Matrix 1.1 (drop legacy r0 versions) ([\#9819](https://github.com/matrix-org/matrix-react-sdk/pull/9819)). + +## 🐛 Bug Fixes + * Fix export type "Current timeline" to match its behaviour to its name ([\#11426](https://github.com/matrix-org/matrix-react-sdk/pull/11426)). Fixes vector-im/element-web#25988. + * Fix Room Settings > Notifications file upload input being shown superfluously ([\#11415](https://github.com/matrix-org/matrix-react-sdk/pull/11415)). Fixes vector-im/element-web#18392. + * Simplify registration with email validation ([\#11398](https://github.com/matrix-org/matrix-react-sdk/pull/11398)). Fixes vector-im/element-web#25832 vector-im/element-web#23601 and vector-im/element-web#22297. + * correct home server URL ([\#11391](https://github.com/matrix-org/matrix-react-sdk/pull/11391)). Fixes vector-im/element-web#25931. Contributed by @NSV1991. + * Include non-matching DMs in Spotlight recent conversations when the DM's userId is part of the search API results ([\#11374](https://github.com/matrix-org/matrix-react-sdk/pull/11374)). Contributed by @mgcm. + * Fix useRoomMembers missing updates causing incorrect membership counts ([\#11392](https://github.com/matrix-org/matrix-react-sdk/pull/11392)). Fixes vector-im/element-web#17096. + * Show error when searching public rooms fails ([\#11378](https://github.com/matrix-org/matrix-react-sdk/pull/11378)). + +Changes in [3.78.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.78.0) (2023-08-15) +===================================================================================================== + +## 🦖 Deprecations + * Deprecate camelCase config options ([\#11261](https://github.com/matrix-org/matrix-react-sdk/pull/11261)). + +## ✨ Features + * Allow knocking rooms ([\#11353](https://github.com/matrix-org/matrix-react-sdk/pull/11353)). Contributed by @charlynguyen. + * Support adding space-restricted joins on rooms not members of those spaces ([\#9017](https://github.com/matrix-org/matrix-react-sdk/pull/9017)). Fixes vector-im/element-web#19213. + * Clear requiresClient and show pop-out if widget-api fails to ready ([\#11321](https://github.com/matrix-org/matrix-react-sdk/pull/11321)). Fixes vector-im/customer-retainer#73. + * Bump pagination sizes due to hidden events ([\#11342](https://github.com/matrix-org/matrix-react-sdk/pull/11342)). + * Remove display of key backup signatures from backup settings ([\#11333](https://github.com/matrix-org/matrix-react-sdk/pull/11333)). + * Use PassphraseFields in ExportE2eKeysDialog to enforce minimum passphrase complexity ([\#11222](https://github.com/matrix-org/matrix-react-sdk/pull/11222)). Fixes vector-im/element-web#9478. + +## 🐛 Bug Fixes + * Fix "Export chat" not respecting configured time format in plain text mode ([\#10696](https://github.com/matrix-org/matrix-react-sdk/pull/10696)). Fixes vector-im/element-web#23838. Contributed by @rashmitpankhania. + * Fix some missing 1-count pluralisations around event list summaries ([\#11371](https://github.com/matrix-org/matrix-react-sdk/pull/11371)). Fixes vector-im/element-web#25925. + * Fix create subspace dialog not working for public space creation ([\#11367](https://github.com/matrix-org/matrix-react-sdk/pull/11367)). Fixes vector-im/element-web#25916. + * Search for users on paste ([\#11304](https://github.com/matrix-org/matrix-react-sdk/pull/11304)). Fixes vector-im/element-web#17523. Contributed by @peterscheu-aceart. + * Fix AppTile context menu not always showing up when it has options ([\#11358](https://github.com/matrix-org/matrix-react-sdk/pull/11358)). Fixes vector-im/element-web#25914. + * Fix clicking on home all rooms space notification not working ([\#11337](https://github.com/matrix-org/matrix-react-sdk/pull/11337)). Fixes vector-im/element-web#22844. + * Fix joining a suggested room switching space away ([\#11347](https://github.com/matrix-org/matrix-react-sdk/pull/11347)). Fixes vector-im/element-web#25838. + * Fix home/all rooms context menu in space panel ([\#11350](https://github.com/matrix-org/matrix-react-sdk/pull/11350)). Fixes vector-im/element-web#25896. + * Make keyboard handling in and out of autocomplete completions consistent ([\#11344](https://github.com/matrix-org/matrix-react-sdk/pull/11344)). Fixes vector-im/element-web#25878. + * De-duplicate reactions by sender to account for faulty/malicious servers ([\#11340](https://github.com/matrix-org/matrix-react-sdk/pull/11340)). Fixes vector-im/element-web#25872. + * Fix disable_3pid_login being ignored for the email field ([\#11335](https://github.com/matrix-org/matrix-react-sdk/pull/11335)). Fixes vector-im/element-web#25863. + * Upgrade wysiwyg editor for ctrl+backspace windows fix ([\#11324](https://github.com/matrix-org/matrix-react-sdk/pull/11324)). Fixes vector-im/verticals-internal#102. + * Unhide the view source event toggle - it works well enough ([\#11336](https://github.com/matrix-org/matrix-react-sdk/pull/11336)). Fixes vector-im/element-web#25861. + +Changes in [3.77.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.77.1) (2023-08-04) +===================================================================================================== + +## 🐛 Bug Fixes + * Revert to using the /presence API for presence ([\#11366](https://github.com/matrix-org/matrix-react-sdk/pull/11366)) + +Changes in [3.77.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.77.0) (2023-08-01) +===================================================================================================== + +## 🦖 Deprecations + * Deprecate camelCase config options ([\#11261](https://github.com/matrix-org/matrix-react-sdk/pull/11261)). + +## ✨ Features + * Do not show "Forget room" button in Room View header for guest users ([\#10898](https://github.com/matrix-org/matrix-react-sdk/pull/10898)). Contributed by @spantaleev. + * Switch to updating presence via /sync calls instead of PUT /presence ([\#11223](https://github.com/matrix-org/matrix-react-sdk/pull/11223)). Fixes vector-im/element-web#20809 vector-im/element-web#13877 and vector-im/element-web#4813. + * Fix blockquote colour contrast ([\#11299](https://github.com/matrix-org/matrix-react-sdk/pull/11299)). Fixes matrix-org/element-web-rageshakes#21800. + * Don't hide room header buttons in video rooms and rooms with a call ([\#9712](https://github.com/matrix-org/matrix-react-sdk/pull/9712)). Fixes vector-im/element-web#23900. + * OIDC: Persist details in session storage, create store ([\#11302](https://github.com/matrix-org/matrix-react-sdk/pull/11302)). Fixes vector-im/element-web#25710. Contributed by @kerryarchibald. + * Allow setting room join rule to knock ([\#11248](https://github.com/matrix-org/matrix-react-sdk/pull/11248)). Contributed by @charlynguyen. + * Retry joins on 524 (Cloudflare timeout) also ([\#11296](https://github.com/matrix-org/matrix-react-sdk/pull/11296)). Fixes vector-im/element-web#8776. + * Make sure users returned by the homeserver search API are displayed. Don't silently drop any. ([\#9556](https://github.com/matrix-org/matrix-react-sdk/pull/9556)). Fixes vector-im/element-web#24422. Contributed by @maxmalek. + * Offer to unban user during invite if inviter has sufficient permissions ([\#11256](https://github.com/matrix-org/matrix-react-sdk/pull/11256)). Fixes vector-im/element-web#3222. + * Split join and goto slash commands, the latter shouldn't auto_join ([\#11259](https://github.com/matrix-org/matrix-react-sdk/pull/11259)). Fixes vector-im/element-web#10128. + * Integration work for rich text editor 2.3.1 ([\#11172](https://github.com/matrix-org/matrix-react-sdk/pull/11172)). Contributed by @alunturner. + * Compound color pass ([\#11079](https://github.com/matrix-org/matrix-react-sdk/pull/11079)). Fixes vector-im/internal-planning#450 and vector-im/element-web#25547. + * Warn when demoting self via /op and /deop slash commands ([\#11214](https://github.com/matrix-org/matrix-react-sdk/pull/11214)). Fixes vector-im/element-web#13726. + +## 🐛 Bug Fixes + * Fix edge case with sent indicator being drawn when it shouldn't be ([\#11320](https://github.com/matrix-org/matrix-react-sdk/pull/11320)). + * Use correct translation function for WYSIWYG buttons ([\#11315](https://github.com/matrix-org/matrix-react-sdk/pull/11315)). Fixes vector-im/verticals-internal#109. + * Handle empty own profile ([\#11319](https://github.com/matrix-org/matrix-react-sdk/pull/11319)). Fixes vector-im/element-web#25510. + * Fix peeked rooms showing up in historical ([\#11316](https://github.com/matrix-org/matrix-react-sdk/pull/11316)). Fixes vector-im/element-web#22473. + * Ensure consistency when rendering the sent event indicator ([\#11314](https://github.com/matrix-org/matrix-react-sdk/pull/11314)). Fixes vector-im/element-web#17937. + * Prevent re-filtering user directory results in spotlight ([\#11290](https://github.com/matrix-org/matrix-react-sdk/pull/11290)). Fixes vector-im/element-web#24422. + * Fix GIF label on dark theme ([\#11312](https://github.com/matrix-org/matrix-react-sdk/pull/11312)). Fixes vector-im/element-web#25836. + * Fix issues around room notification settings flaking out ([\#11306](https://github.com/matrix-org/matrix-react-sdk/pull/11306)). Fixes vector-im/element-web#16472 vector-im/element-web#21309 and vector-im/element-web#6828. + * Fix invite dialog showing the same user multiple times ([\#11308](https://github.com/matrix-org/matrix-react-sdk/pull/11308)). Fixes vector-im/element-web#25578. + * Don't show composer send button if user cannot send ([\#11298](https://github.com/matrix-org/matrix-react-sdk/pull/11298)). Fixes vector-im/element-web#25825. + * Restore color for sender in imageview ([\#11289](https://github.com/matrix-org/matrix-react-sdk/pull/11289)). Fixes vector-im/element-web#25822. + * Fix changelog dialog heading size ([\#11286](https://github.com/matrix-org/matrix-react-sdk/pull/11286)). Fixes vector-im/element-web#25789. + * Restore offline presence badge color ([\#11287](https://github.com/matrix-org/matrix-react-sdk/pull/11287)). Fixes vector-im/element-web#25792. + * Fix bubble message layout avatar overlap ([\#11284](https://github.com/matrix-org/matrix-react-sdk/pull/11284)). Fixes vector-im/element-web#25818. + * Fix voice call tile size ([\#11285](https://github.com/matrix-org/matrix-react-sdk/pull/11285)). Fixes vector-im/element-web#25684. + * Fix layout of sessions tab buttons ([\#11279](https://github.com/matrix-org/matrix-react-sdk/pull/11279)). Fixes vector-im/element-web#25545. + * Don't bother showing redundant tooltip on space menu ([\#11276](https://github.com/matrix-org/matrix-react-sdk/pull/11276)). Fixes vector-im/element-web#20380. + * Remove reply fallback from notifications ([\#11278](https://github.com/matrix-org/matrix-react-sdk/pull/11278)). Fixes vector-im/element-web#17859. + * Populate info.duration for audio & video file uploads ([\#11225](https://github.com/matrix-org/matrix-react-sdk/pull/11225)). Fixes vector-im/element-web#17720. + * Hide widget menu button if it there are no options available ([\#11257](https://github.com/matrix-org/matrix-react-sdk/pull/11257)). Fixes vector-im/element-web#24826. + * Fix colour regressions ([\#11273](https://github.com/matrix-org/matrix-react-sdk/pull/11273)). Fixes vector-im/element-web#25788, vector-im/element-web#25808 vector-im/element-web#25811 and vector-im/element-web#25812. + * Fix room view not properly maintaining scroll position ([\#11274](https://github.com/matrix-org/matrix-react-sdk/pull/11274)). Fixes vector-im/element-web#25810. + * Prevent user from accidentally double clicking user info admin actions ([\#11254](https://github.com/matrix-org/matrix-react-sdk/pull/11254)). Fixes vector-im/element-web#10944. + * Fix missing metaspace notification badges ([\#11269](https://github.com/matrix-org/matrix-react-sdk/pull/11269)). Fixes vector-im/element-web#25679. + * Fix clicking MXID in timeline going to matrix.to ([\#11263](https://github.com/matrix-org/matrix-react-sdk/pull/11263)). Fixes vector-im/element-web#23342. + * Restoring optional ligatures by resetting letter-spacing ([\#11202](https://github.com/matrix-org/matrix-react-sdk/pull/11202)). Fixes vector-im/element-web#25727. + * Allow emoji presentation selector to not break BigEmoji styling ([\#11253](https://github.com/matrix-org/matrix-react-sdk/pull/11253)). Fixes vector-im/element-web#17848. + * Make event highliht use primary content token ([\#11255](https://github.com/matrix-org/matrix-react-sdk/pull/11255)). + * Fix event info events size and color ([\#11252](https://github.com/matrix-org/matrix-react-sdk/pull/11252)). Fixes vector-im/element-web#25778. + * Fix color mapping for blockquote border ([\#11251](https://github.com/matrix-org/matrix-react-sdk/pull/11251)). Fixes vector-im/element-web#25782. + * Strip emoji variation when searching emoji by emoji ([\#11221](https://github.com/matrix-org/matrix-react-sdk/pull/11221)). Fixes vector-im/element-web#18703. + +Changes in [3.76.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.76.0) (2023-07-18) +===================================================================================================== + +## 🔒 Security + * Fixes for [CVE-2023-37259](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE-2023-37259) / [GHSA-c9vx-2g7w-rp65](https://github.com/matrix-org/matrix-react-sdk/security/advisories/GHSA-c9vx-2g7w-rp65) + +## ✨ Features + * GYU: Update banner ([\#11211](https://github.com/matrix-org/matrix-react-sdk/pull/11211)). Fixes vector-im/element-web#25530. Contributed by @justjanne. + * Linkify mxc:// URLs as links to your media repo ([\#11213](https://github.com/matrix-org/matrix-react-sdk/pull/11213)). Fixes vector-im/element-web#6942. + * OIDC: Log in ([\#11199](https://github.com/matrix-org/matrix-react-sdk/pull/11199)). Fixes vector-im/element-web#25657. Contributed by @kerryarchibald. + * Handle all permitted url schemes in linkify ([\#11215](https://github.com/matrix-org/matrix-react-sdk/pull/11215)). Fixes vector-im/element-web#4457 and vector-im/element-web#8720. + * Autoapprove Element Call oidc requests ([\#11209](https://github.com/matrix-org/matrix-react-sdk/pull/11209)). Contributed by @toger5. + * Allow creating knock rooms ([\#11182](https://github.com/matrix-org/matrix-react-sdk/pull/11182)). Contributed by @charlynguyen. + * feat(faq): remove keyboard shortcuts button ([\#9342](https://github.com/matrix-org/matrix-react-sdk/pull/9342)). Fixes vector-im/element-web#22625. Contributed by @gefgu. + * Expose and pre-populate thread ID in devtools dialog ([\#10953](https://github.com/matrix-org/matrix-react-sdk/pull/10953)). + * Hide URL preview if it will be empty ([\#9029](https://github.com/matrix-org/matrix-react-sdk/pull/9029)). + * Change wording from avatar to profile picture ([\#7015](https://github.com/matrix-org/matrix-react-sdk/pull/7015)). Fixes vector-im/element-meta#1331. Contributed by @aaronraimist. + * Quick and dirty devtool to explore state history ([\#11197](https://github.com/matrix-org/matrix-react-sdk/pull/11197)). + * Consider more user inputs when calculating zxcvbn score ([\#11180](https://github.com/matrix-org/matrix-react-sdk/pull/11180)). + * GYU: Account Notification Settings ([\#11008](https://github.com/matrix-org/matrix-react-sdk/pull/11008)). Fixes vector-im/element-web#24567. Contributed by @justjanne. + * Compound Typography pass ([\#11103](https://github.com/matrix-org/matrix-react-sdk/pull/11103)). Fixes vector-im/element-web#25548. + * OIDC: navigate to authorization endpoint ([\#11096](https://github.com/matrix-org/matrix-react-sdk/pull/11096)). Fixes vector-im/element-web#25574. Contributed by @kerryarchibald. + +## 🐛 Bug Fixes + * Fix missing metaspace notification badges ([\#11269](https://github.com/matrix-org/matrix-react-sdk/pull/11269)). Fixes vector-im/element-web#25679. + * Make checkboxes less rounded ([\#11224](https://github.com/matrix-org/matrix-react-sdk/pull/11224)). Contributed by @andybalaam. + * GYU: Fix issues with audible keywords without activated mentions ([\#11218](https://github.com/matrix-org/matrix-react-sdk/pull/11218)). Contributed by @justjanne. + * PosthogAnalytics unwatch settings on logout ([\#11207](https://github.com/matrix-org/matrix-react-sdk/pull/11207)). Fixes vector-im/element-web#25703. + * Avoid trying to set room account data for pinned events as guest ([\#11216](https://github.com/matrix-org/matrix-react-sdk/pull/11216)). Fixes vector-im/element-web#6300. + * GYU: Disable sound for DMs checkbox when DM notifications are disabled ([\#11210](https://github.com/matrix-org/matrix-react-sdk/pull/11210)). Contributed by @justjanne. + * force to allow calls without video and audio in embedded mode ([\#11131](https://github.com/matrix-org/matrix-react-sdk/pull/11131)). Contributed by @EnricoSchw. + * Fix room tile text clipping ([\#11196](https://github.com/matrix-org/matrix-react-sdk/pull/11196)). Fixes vector-im/element-web#25718. + * Handle newlines in user pills ([\#11166](https://github.com/matrix-org/matrix-react-sdk/pull/11166)). Fixes vector-im/element-web#10994. + * Limit width of user menu in space panel ([\#11192](https://github.com/matrix-org/matrix-react-sdk/pull/11192)). Fixes vector-im/element-web#22627. + * Add isLocation to ComposerEvent analytics events ([\#11187](https://github.com/matrix-org/matrix-react-sdk/pull/11187)). Contributed by @andybalaam. + * Fix: hide unsupported login elements ([\#11185](https://github.com/matrix-org/matrix-react-sdk/pull/11185)). Fixes vector-im/element-web#25711. Contributed by @kerryarchibald. + * Scope smaller font size to user info panel ([\#11178](https://github.com/matrix-org/matrix-react-sdk/pull/11178)). Fixes vector-im/element-web#25683. + * Apply i18n to strings in the html export ([\#11176](https://github.com/matrix-org/matrix-react-sdk/pull/11176)). + * Inhibit url previews on MXIDs containing slashes same as those without ([\#11160](https://github.com/matrix-org/matrix-react-sdk/pull/11160)). + * Make event info size consistent with state events ([\#11181](https://github.com/matrix-org/matrix-react-sdk/pull/11181)). + * Fix markdown content spacing ([\#11177](https://github.com/matrix-org/matrix-react-sdk/pull/11177)). Fixes vector-im/element-web#25685. + * Fix font-family definition for emojis ([\#11170](https://github.com/matrix-org/matrix-react-sdk/pull/11170)). Fixes vector-im/element-web#25686. + * Fix spurious error sending receipt in thread errors ([\#11157](https://github.com/matrix-org/matrix-react-sdk/pull/11157)). + * Consider the empty push rule actions array equiv to deprecated dont_notify ([\#11155](https://github.com/matrix-org/matrix-react-sdk/pull/11155)). Fixes vector-im/element-web#25674. + * Only trap escape key for cancel reply if there is a reply ([\#11140](https://github.com/matrix-org/matrix-react-sdk/pull/11140)). Fixes vector-im/element-web#25640. + * Update linkify to 4.1.1 ([\#11132](https://github.com/matrix-org/matrix-react-sdk/pull/11132)). Fixes vector-im/element-web#23806. + +Changes in [3.75.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.75.0) (2023-07-04) +===================================================================================================== + +## 🦖 Deprecations + * Remove `feature_favourite_messages` as it is has been abandoned for now ([\#11097](https://github.com/matrix-org/matrix-react-sdk/pull/11097)). Fixes vector-im/element-web#25555. + +## ✨ Features + * Don't setup keys on login when encryption is force disabled ([\#11125](https://github.com/matrix-org/matrix-react-sdk/pull/11125)). Contributed by @kerryarchibald. + * OIDC: attempt dynamic client registration ([\#11074](https://github.com/matrix-org/matrix-react-sdk/pull/11074)). Fixes vector-im/element-web#25468 and vector-im/element-web#25467. Contributed by @kerryarchibald. + * OIDC: Check static client registration and add login flow ([\#11088](https://github.com/matrix-org/matrix-react-sdk/pull/11088)). Fixes vector-im/element-web#25467. Contributed by @kerryarchibald. + * Improve message body output from plain text editor ([\#11124](https://github.com/matrix-org/matrix-react-sdk/pull/11124)). Contributed by @alunturner. + * Disable encryption toggle in room settings when force disabled ([\#11122](https://github.com/matrix-org/matrix-react-sdk/pull/11122)). Contributed by @kerryarchibald. + * Add .well-known config option to force disable encryption on room creation ([\#11120](https://github.com/matrix-org/matrix-react-sdk/pull/11120)). Contributed by @kerryarchibald. + * Handle permalinks in room topic ([\#11115](https://github.com/matrix-org/matrix-react-sdk/pull/11115)). Fixes vector-im/element-web#23395. + * Add at room avatar for RTE ([\#11106](https://github.com/matrix-org/matrix-react-sdk/pull/11106)). Contributed by @alunturner. + * Remove new room breadcrumbs ([\#11104](https://github.com/matrix-org/matrix-react-sdk/pull/11104)). + * Update rich text editor dependency and associated changes ([\#11098](https://github.com/matrix-org/matrix-react-sdk/pull/11098)). Contributed by @alunturner. + * Implement new model, hooks and reconcilation code for new GYU notification settings ([\#11089](https://github.com/matrix-org/matrix-react-sdk/pull/11089)). Contributed by @justjanne. + * Allow maintaining a different right panel width for thread panels ([\#11064](https://github.com/matrix-org/matrix-react-sdk/pull/11064)). Fixes vector-im/element-web#25487. + * Make AppPermission pane scrollable ([\#10954](https://github.com/matrix-org/matrix-react-sdk/pull/10954)). Fixes vector-im/element-web#25438 and vector-im/element-web#25511. Contributed by @luixxiul. + * Integrate compound design tokens ([\#11091](https://github.com/matrix-org/matrix-react-sdk/pull/11091)). Fixes vector-im/internal-planning#450. + * Don't warn about the effects of redacting state events when redacting non-state-events ([\#11071](https://github.com/matrix-org/matrix-react-sdk/pull/11071)). Fixes vector-im/element-web#8478. + * Allow specifying help URLs in config.json ([\#11070](https://github.com/matrix-org/matrix-react-sdk/pull/11070)). Fixes vector-im/element-web#15268. + +## 🐛 Bug Fixes + * Fix spurious notifications on non-live events ([\#11133](https://github.com/matrix-org/matrix-react-sdk/pull/11133)). Fixes vector-im/element-web#24336. + * Prevent auto-translation within composer ([\#11114](https://github.com/matrix-org/matrix-react-sdk/pull/11114)). Fixes vector-im/element-web#25624. + * Fix caret jump when backspacing into empty line at beginning of editor ([\#11128](https://github.com/matrix-org/matrix-react-sdk/pull/11128)). Fixes vector-im/element-web#22335. + * Fix server picker not allowing you to switch from custom to default ([\#11127](https://github.com/matrix-org/matrix-react-sdk/pull/11127)). Fixes vector-im/element-web#25650. + * Consider the unthreaded read receipt for Unread dot state ([\#11117](https://github.com/matrix-org/matrix-react-sdk/pull/11117)). Fixes vector-im/element-web#24229. + * Increase RTE resilience ([\#11111](https://github.com/matrix-org/matrix-react-sdk/pull/11111)). Fixes vector-im/element-web#25277. Contributed by @alunturner. + * Fix RoomView ignoring alias lookup errors due to them not knowing the roomId ([\#11099](https://github.com/matrix-org/matrix-react-sdk/pull/11099)). Fixes vector-im/element-web#24783 and vector-im/element-web#25562. + * Fix style inconsistencies on SecureBackupPanel ([\#11102](https://github.com/matrix-org/matrix-react-sdk/pull/11102)). Fixes vector-im/element-web#25615. Contributed by @luixxiul. + * Remove unknown MXIDs from invite suggestions ([\#11055](https://github.com/matrix-org/matrix-react-sdk/pull/11055)). Fixes vector-im/element-web#25446. + * Reduce volume of ring sounds to normalised levels ([\#9143](https://github.com/matrix-org/matrix-react-sdk/pull/9143)). Contributed by @JMoVS. + * Fix slash commands not being enabled in certain cases ([\#11090](https://github.com/matrix-org/matrix-react-sdk/pull/11090)). Fixes vector-im/element-web#25572. + * Prevent escape in threads from sending focus to main timeline composer ([\#11061](https://github.com/matrix-org/matrix-react-sdk/pull/11061)). Fixes vector-im/element-web#23397. + +Changes in [3.74.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.74.0) (2023-06-20) +===================================================================================================== + +## ✨ Features + * OIDC: add delegatedauthentication to validated server config ([\#11053](https://github.com/matrix-org/matrix-react-sdk/pull/11053)). Contributed by @kerryarchibald. + * Allow image pasting in plain mode in RTE ([\#11056](https://github.com/matrix-org/matrix-react-sdk/pull/11056)). Contributed by @alunturner. + * Show room options menu if "UIComponent.roomOptionsMenu" is enabled ([\#10365](https://github.com/matrix-org/matrix-react-sdk/pull/10365)). Contributed by @maheichyk. + * Allow image pasting in rich text mode in RTE ([\#11049](https://github.com/matrix-org/matrix-react-sdk/pull/11049)). Contributed by @alunturner. + * Update voice broadcast redaction to use MSC3912 `with_rel_type` instead of `with_relations` ([\#11014](https://github.com/matrix-org/matrix-react-sdk/pull/11014)). Fixes vector-im/element-web#25471. + * Add config to skip widget_build_url for DM rooms ([\#11044](https://github.com/matrix-org/matrix-react-sdk/pull/11044)). Fixes vector-im/customer-retainer#74. + * Inhibit interactions on forward dialog message previews ([\#11025](https://github.com/matrix-org/matrix-react-sdk/pull/11025)). Fixes vector-im/element-web#23459. + * Removed `DecryptionFailureBar.tsx` ([\#11027](https://github.com/matrix-org/matrix-react-sdk/pull/11027)). Fixes vector-im/element-meta#1358. Contributed by @florianduros. + +## 🐛 Bug Fixes + * Fix translucent `TextualEvent` on search results panel ([\#10810](https://github.com/matrix-org/matrix-react-sdk/pull/10810)). Fixes vector-im/element-web#25292. Contributed by @luixxiul. + * Matrix matrix scheme permalink constructor not stripping query params ([\#11060](https://github.com/matrix-org/matrix-react-sdk/pull/11060)). Fixes vector-im/element-web#25535. + * Fix: "manually verify by text" does nothing ([\#11059](https://github.com/matrix-org/matrix-react-sdk/pull/11059)). Fixes vector-im/element-web#25375. Contributed by @kerryarchibald. + * Make group calls respect the ICE fallback setting ([\#11047](https://github.com/matrix-org/matrix-react-sdk/pull/11047)). Fixes vector-im/voip-internal#65. + * Align list items on the tooltip to the start ([\#11041](https://github.com/matrix-org/matrix-react-sdk/pull/11041)). Fixes vector-im/element-web#25355. Contributed by @luixxiul. + * Clear thread panel event permalink when changing rooms ([\#11024](https://github.com/matrix-org/matrix-react-sdk/pull/11024)). Fixes vector-im/element-web#25484. + * Fix spinner placement on pinned widgets being reloaded ([\#10970](https://github.com/matrix-org/matrix-react-sdk/pull/10970)). Fixes vector-im/element-web#25431. Contributed by @luixxiul. + Changes in [3.73.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.73.1) (2023-06-09) ===================================================================================================== diff --git a/README.md b/README.md index 9916bcb928a..f3f34939a96 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ [![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) -[![matrix-react-sdk](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/ppvnzg/develop&style=flat&logo=cypress)](https://dashboard.cypress.io/projects/ppvnzg/runs) -[![This project is using Percy.io for visual regression testing.](https://percy.io/static/images/percy-badge.svg)](https://percy.io/dfde73bd/matrix-react-sdk) -[![Weblate](https://translate.element.io/widgets/element-web/-/matrix-react-sdk/svg-badge.svg)](https://translate.element.io/engage/element-web/) +[![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) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) @@ -28,10 +27,6 @@ As of Aug 2018, the only skin that exists is be considered as a single project (for instance, matrix-react-sdk bugs are currently filed against vector-im/element-web rather than this project). -## Translation Status - -[![Translation status](https://translate.element.io/widgets/element-web/-/multi-auto.svg)](https://translate.element.io/engage/element-web/?utm_source=widget) - ## Developer Guide Platform Targets: @@ -209,7 +204,5 @@ Now the yarn commands should work as normal. ### End-to-End tests -Make sure you've got your Element development server running (by doing `yarn -start` in element-web), and then in this project, run `yarn run test:cypress`. See -[`docs/cypress.md`](https://github.com/matrix-org/matrix-react-sdk/blob/develop/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/__mocks__/languages.json b/__mocks__/languages.json index 36ec89561b2..35a400808b8 100644 --- a/__mocks__/languages.json +++ b/__mocks__/languages.json @@ -1,10 +1,4 @@ { - "en": { - "fileName": "en_EN.json", - "label": "English" - }, - "en-us": { - "fileName": "en_US.json", - "label": "English (US)" - } + "en": "en_EN.json", + "en-us": "en_US.json" } diff --git a/__mocks__/workerFactoryMock.js b/__mocks__/workerFactoryMock.js new file mode 100644 index 00000000000..56dc085cd3c --- /dev/null +++ b/__mocks__/workerFactoryMock.js @@ -0,0 +1,19 @@ +/* +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. +*/ + +export default function workerFactory(options) { + return jest.fn; +} diff --git a/__mocks__/workerMock.js b/__mocks__/workerMock.js deleted file mode 100644 index 6ee585673ed..00000000000 --- a/__mocks__/workerMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = jest.fn(); diff --git a/cypress-ci-reporter-config.json b/cypress-ci-reporter-config.json deleted file mode 100644 index f43509ea308..00000000000 --- a/cypress-ci-reporter-config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "reporterEnabled": "spec, mocha-junit-reporter", - "mochaJunitReporterReporterOptions": { - "mochaFile": "cypress/results/results-[hash].xml", - "useFullSuiteTitle": true - } -} diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index b57fe7f6c4a..00000000000 --- a/cypress.config.ts +++ /dev/null @@ -1,41 +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"; - -export default defineConfig({ - videoUploadOnPasses: false, - projectId: "ppvnzg", - experimentalInteractiveRunEvents: true, - defaultCommandTimeout: 10000, - chromeWebSecurity: false, - e2e: { - setupNodeEvents(on, config) { - return require("./cypress/plugins/index.ts").default(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.0-rc1", - HOMESERVER: "synapse", - }, - retries: { - runMode: 4, - openMode: 0, - }, -}); 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 a59fb64ab46..00000000000 --- a/cypress/e2e/audio-player/audio-player.spec.ts +++ /dev/null @@ -1,383 +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"); - }; - - /** - * 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"); - - 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"); - - 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"); - }); - }); - - // Take snapshots - 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 2b49b5e32e0..00000000000 --- a/cypress/e2e/composer/composer.spec.ts +++ /dev/null @@ -1,354 +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/@types/event"; - -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").then(() => { - cy.createRoom({ name: "Composing Room" }); - }); - cy.viewRoomByName("Composing Room"); - }); - - 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").then(() => { - cy.createRoom({ name: "Composing Room" }); - }); - cy.viewRoomByName("Composing Room"); - }); - - 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("span.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", () => { - it("autocomplete behaviour tests", () => { - // Setup 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], - }); - }); - - cy.viewRoomByName("Bob"); - - // 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/create-room/create-room.spec.ts b/cypress/e2e/create-room/create-room.spec.ts deleted file mode 100644 index d51e683abf4..00000000000 --- a/cypress/e2e/create-room/create-room.spec.ts +++ /dev/null @@ -1,67 +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 Chainable = Cypress.Chainable; - -function openCreateRoomDialog(): Chainable> { - cy.findByRole("button", { name: "Add room" }).click(); - cy.findByRole("menuitem", { name: "New room" }).click(); - return cy.get(".mx_CreateRoomDialog"); -} - -describe("Create Room", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Jim"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should allow us to create a public room with name, topic & address set", () => { - const name = "Test room 1"; - const topic = "This room is dedicated to this test and this test only!"; - - openCreateRoomDialog().within(() => { - // Fill name & topic - cy.findByRole("textbox", { name: "Name" }).type(name); - cy.findByRole("textbox", { name: "Topic (optional)" }).type(topic); - // Change room to public - cy.findByRole("button", { name: "Room visibility" }).click(); - cy.findByRole("option", { name: "Public room" }).click(); - // Fill room address - cy.findByRole("textbox", { name: "Room address" }).type("test-room-1"); - // Submit - cy.findByRole("button", { name: "Create room" }).click(); - }); - - cy.url().should("contain", "/#/room/#test-room-1:localhost"); - - cy.get(".mx_RoomHeader").within(() => { - cy.findByText(name); - cy.findByText(topic); - }); - }); -}); diff --git a/cypress/e2e/crypto/complete-security.spec.ts b/cypress/e2e/crypto/complete-security.spec.ts deleted file mode 100644 index b598829b86a..00000000000 --- a/cypress/e2e/crypto/complete-security.spec.ts +++ /dev/null @@ -1,84 +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 { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { handleVerificationRequest, logIntoElement, waitForVerificationRequest } from "./utils"; -import { CypressBot } from "../../support/bot"; -import { skipIfRustCrypto } from "../../support/util"; - -describe("Complete security", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - 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"); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should go straight to the welcome screen if we have no signed device", () => { - const username = Cypress._.uniqueId("user_"); - const password = "supersecret"; - cy.registerUser(homeserver, username, password, "Jeff"); - logIntoElement(homeserver.baseUrl, username, password); - cy.findByText("Welcome Jeff"); - }); - - it("should walk through device verification if we have a signed device", () => { - skipIfRustCrypto(); - - // create a new user, and have it bootstrap cross-signing - let botClient: CypressBot; - cy.getBot(homeserver, { displayName: "Jeff" }) - .then(async (bot) => { - botClient = bot; - await bot.bootstrapCrossSigning({}); - }) - .then(() => { - // now log in, in Element. We go in through the login page because otherwise the device setup flow - // doesn't get triggered - console.log("%cAccount set up; logging in user", "font-weight: bold; font-size:x-large"); - logIntoElement(homeserver.baseUrl, botClient.getSafeUserId(), botClient.__cypress_password); - - // we should see a prompt for a device verification - cy.findByRole("heading", { name: "Verify this device" }); - const botVerificationRequestPromise = waitForVerificationRequest(botClient); - cy.findByRole("button", { name: "Verify with another device" }).click(); - - // accept the verification request on the "bot" side - cy.wrap(botVerificationRequestPromise).then(async (verificationRequest: VerificationRequest) => { - await handleVerificationRequest(verificationRequest); - }); - - // confirm that the emojis match - cy.findByRole("button", { name: "They match" }).click(); - - // we should get the confirmation box - cy.findByText(/You've successfully verified/); - - cy.findByRole("button", { name: "Got it" }).click(); - }); - }); -}); diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts deleted file mode 100644 index 17975e88dab..00000000000 --- a/cypress/e2e/crypto/crypto.spec.ts +++ /dev/null @@ -1,390 +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/verification/request/VerificationRequest"; -import type { CypressBot } from "../../support/bot"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; -import { - checkDeviceIsCrossSigned, - EmojiMapping, - handleVerificationRequest, - logIntoElement, - waitForVerificationRequest, -} from "./utils"; -import { skipIfRustCrypto } from "../../support/util"; - -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); - } - }); - }); -} - -/** - * Given a VerificationRequest in a bot client, add cypress commands to: - * - wait for the bot to receive a 'verify by emoji' notification - * - check that the bot sees the same emoji as the application - * - * @param botVerificationRequest - a verification request in a bot client - */ -function doTwoWaySasVerification(botVerificationRequest: VerificationRequest): void { - // on the bot side, wait for the emojis, confirm they match, and return them - const emojiPromise = handleVerificationRequest(botVerificationRequest); - - // 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) => { - expect(emojiBlocks[index].textContent.toLowerCase()).to.eq(emoji[0] + emoji[1]); - }); - }); - }); -} - -const verify = function (this: CryptoTestContext) { - const bobsVerificationRequestPromise = waitForVerificationRequest(this.bob); - - openRoomInfo().within(() => { - cy.findByRole("button", { name: /People \d/ }).click(); // \d is the number of the room members - cy.findByText("Bob").click(); - cy.findByRole("button", { name: "Verify" }).click(); - cy.findByRole("button", { name: "Start Verification" }).click(); - cy.findByRole("button", { name: "Verify by emoji" }).click(); - cy.wrap(bobsVerificationRequestPromise).then((request: VerificationRequest) => { - doTwoWaySasVerification(request); - }); - 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; - - beforeEach(function () { - cy.startHomeserver("default") - .as("homeserver") - .then((homeserver: HomeserverInstance) => { - cy.initTestUser(homeserver, "Alice", undefined, "alice_").then((credentials) => { - aliceCredentials = credentials; - }); - cy.getBot(homeserver, { - displayName: "Bob", - autoAcceptInvites: false, - userIdPrefix: "bob_", - }).as("bob"); - }); - }); - - afterEach(function (this: CryptoTestContext) { - cy.stopHomeserver(this.homeserver); - }); - - it("setting up secure key backup should work", () => { - skipIfRustCrypto(); - cy.openUserSettings("Security & Privacy"); - cy.findByRole("button", { name: "Set up Secure Backup" }).click(); - cy.get(".mx_Dialog").within(() => { - cy.findByRole("button", { name: "Continue" }).click(); - cy.get(".mx_CreateSecretStorageDialog_recoveryKey code").invoke("text").as("securityKey"); - // 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(); - 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"); - }); - return; - }); - - it("creating a DM should work, being e2e-encrypted / user verification", function (this: CryptoTestContext) { - skipIfRustCrypto(); - 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_e2ee_verified").should("exist"); - - // 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) { - skipIfRustCrypto(); - cy.bootstrapCrossSigning(aliceCredentials); - autoJoin(this.bob); - - // we need to have a room with the other user present, so we can open the verification panel - let roomId: string; - cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] }).then((_room1Id) => { - roomId = _room1Id; - cy.log(`Created test room ${roomId}`); - cy.visit(`/#/room/${roomId}`); - // wait for Bob to join the room, otherwise our attempt to open his user details may race - // with his join. - cy.findByText("Bob joined the room").should("exist"); - }); - - verify.call(this); - }); - - it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) { - skipIfRustCrypto(); - cy.bootstrapCrossSigning(aliceCredentials); - - // bob has a second, not cross-signed, device - cy.loginBot(this.homeserver, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice"); - - autoJoin(this.bob); - - // first create the room, so that we can open the verification panel - cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] }) - .as("testRoomId") - .then((roomId) => { - cy.log(`Created test room ${roomId}`); - cy.visit(`/#/room/${roomId}`); - - // enable encryption - cy.getClient().then((cli) => { - cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" }); - }); - - // wait for Bob to join the room, otherwise our attempt to open his user details may race - // with his join. - cy.findByText("Bob joined the room").should("exist"); - }); - - 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("have.class", "mx_EventTile_verified") - .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("have.class", "mx_EventTile_verified") - .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); - }); - }); -}); - -describe("Verify own device", () => { - 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, { bootstrapCrossSigning: 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("with SAS", function (this: CryptoTestContext) { - 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((request: VerificationRequest) => { - // Handle emoji request and check that emojis are matching - doTwoWaySasVerification(request); - }); - - cy.findByRole("button", { name: "They match" }).click(); - cy.findByRole("button", { name: "Got it" }).click(); - }); - - // Check that our device is now cross-signed - checkDeviceIsCrossSigned(); - }); -}); diff --git a/cypress/e2e/crypto/decryption-failure.spec.ts b/cypress/e2e/crypto/decryption-failure.spec.ts deleted file mode 100644 index 4de2af0e818..00000000000 --- a/cypress/e2e/crypto/decryption-failure.spec.ts +++ /dev/null @@ -1,277 +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 { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; -import { handleVerificationRequest } from "./utils"; -import { skipIfRustCrypto } from "../../support/util"; - -const ROOM_NAME = "Test room"; -const TEST_USER = "Alia"; -const BOT_USER = "Benjamin"; - -type EmojiMapping = [emoji: string, name: string]; - -const waitForVerificationRequest = (cli: MatrixClient): Promise => { - return new Promise((resolve) => { - const onVerificationRequestEvent = (request: VerificationRequest) => { - // @ts-ignore CryptoEvent is not exported to window.matrixcs; using the string value here - cli.off("crypto.verification.request", onVerificationRequestEvent); - resolve(request); - }; - // @ts-ignore - cli.on("crypto.verification.request", onVerificationRequestEvent); - }); -}; - -const checkTimelineNarrow = (button = true) => { - cy.viewport(800, 600); // SVGA - cy.get(".mx_LeftPanel_minimized").should("exist"); // Wait until the left panel is minimized - cy.findByRole("button", { name: "Room info" }).click(); // Open the right panel to make the timeline narrow - cy.get(".mx_BaseCard").should("exist"); - - // Ensure the failure bar does not cover the timeline - cy.get(".mx_RoomView_body .mx_EventTile.mx_EventTile_last").should("be.visible"); - - // Ensure the indicator does not overflow the timeline - cy.findByTestId("decryption-failure-bar-indicator").should("be.visible"); - - if (button) { - // Ensure the button does not overflow the timeline - cy.get("[data-testid='decryption-failure-bar-button']:last-of-type").should("be.visible"); - } - - cy.findByRole("button", { name: "Room info" }).click(); // Close the right panel - cy.get(".mx_BaseCard").should("not.exist"); - cy.viewport(1000, 660); // Reset to the default size -}; - -describe("Decryption Failure Bar", () => { - let homeserver: HomeserverInstance | undefined; - let testUser: UserCredentials | undefined; - let bot: MatrixClient | undefined; - let roomId: string; - - beforeEach(function () { - skipIfRustCrypto(); - cy.startHomeserver("default").then((hs: HomeserverInstance) => { - homeserver = hs; - cy.initTestUser(homeserver, TEST_USER) - .then((creds: UserCredentials) => { - testUser = creds; - }) - .then(() => { - cy.getBot(homeserver, { displayName: BOT_USER }).then((cli) => { - bot = cli; - }); - }) - .then(() => { - cy.createRoom({ name: ROOM_NAME }).then((id) => { - roomId = id; - }); - }) - .then(() => { - cy.inviteUser(roomId, bot.getUserId()); - cy.visit("/#/room/" + roomId); - cy.findByText(BOT_USER + " joined the room").should("exist"); - }) - .then(() => { - cy.getClient() - .then(async (cli) => { - await cli.setRoomEncryption(roomId, { algorithm: "m.megolm.v1.aes-sha2" }); - await bot.setRoomEncryption(roomId, { algorithm: "m.megolm.v1.aes-sha2" }); - }) - .then(() => { - bot.getRoom(roomId).setBlacklistUnverifiedDevices(true); - }); - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it( - "should prompt the user to verify, if this device isn't verified " + - "and there are other verified devices or backups", - () => { - let otherDevice: MatrixClient | undefined; - cy.loginBot(homeserver, testUser.username, testUser.password, { bootstrapCrossSigning: true }) - .then(async (cli) => { - otherDevice = cli; - }) - .then(() => { - cy.botSendMessage(bot, roomId, "test"); - cy.get(".mx_DecryptionFailureBar_start_headline").within(() => { - cy.findByText("Decrypting messages…").should("be.visible"); - cy.findByText("Verify this device to access all messages").should("be.visible"); - }); - - checkTimelineNarrow(); - - cy.get(".mx_DecryptionFailureBar").percySnapshotElement( - "DecryptionFailureBar prompts user to verify", - { - widths: [320, 640], - }, - ); - - cy.get(".mx_DecryptionFailureBar_end").within(() => { - cy.findByText("Resend key requests").should("not.exist"); - cy.findByRole("button", { name: "Verify" }).click(); - }); - - const verificationRequestPromise = waitForVerificationRequest(otherDevice); - cy.findByRole("button", { name: "Verify with another device" }).click(); - cy.findByText("To proceed, please accept the verification request on your other device.").should( - "be.visible", - ); - cy.wrap(verificationRequestPromise).then((verificationRequest: VerificationRequest) => { - cy.wrap(verificationRequest.accept()); - cy.wrap( - handleVerificationRequest(verificationRequest), - // extra timeout, as this sometimes takes a while - { timeout: 30_000 }, - ).then((emojis: EmojiMapping[]) => { - cy.get(".mx_VerificationShowSas_emojiSas_block").then((emojiBlocks) => { - emojis.forEach((emoji: EmojiMapping, index: number) => { - expect(emojiBlocks[index].textContent.toLowerCase()).to.eq(emoji[0] + emoji[1]); - }); - }); - }); - }); - }); - cy.findByRole("button", { name: "They match" }).click(); - cy.get(".mx_VerificationPanel_verified_section .mx_E2EIcon_verified").should("exist"); - cy.findByRole("button", { name: "Got it" }).click(); - - cy.get(".mx_DecryptionFailureBar_start_headline").within(() => { - cy.findByText("Open another device to load encrypted messages").should("be.visible"); - }); - - checkTimelineNarrow(); - - cy.get(".mx_DecryptionFailureBar").percySnapshotElement( - "DecryptionFailureBar prompts user to open another device, with Resend Key Requests button", - { - widths: [320, 640], - }, - ); - - cy.intercept("/_matrix/client/r0/sendToDevice/m.room_key_request/*").as("keyRequest"); - cy.findByRole("button", { name: "Resend key requests" }).click(); - cy.wait("@keyRequest"); - cy.get(".mx_DecryptionFailureBar_end").within(() => { - cy.findByText("Resend key requests").should("not.exist"); - cy.findByRole("button", { name: "View your device list" }).should("be.visible"); - }); - - checkTimelineNarrow(); - - cy.get(".mx_DecryptionFailureBar").percySnapshotElement( - "DecryptionFailureBar prompts user to open another device, without Resend Key Requests button", - { - widths: [320, 640], - }, - ); - }, - ); - - it( - "should prompt the user to reset keys, if this device isn't verified " + - "and there are no other verified devices or backups", - () => { - cy.loginBot(homeserver, testUser.username, testUser.password, { bootstrapCrossSigning: true }).then( - async (cli) => { - await cli.logout(true); - }, - ); - - cy.botSendMessage(bot, roomId, "test"); - cy.get(".mx_DecryptionFailureBar_start_headline").within(() => { - cy.findByText("Reset your keys to prevent future decryption errors").should("be.visible"); - }); - - checkTimelineNarrow(); - - cy.get(".mx_DecryptionFailureBar").percySnapshotElement("DecryptionFailureBar prompts user to reset keys", { - widths: [320, 640], - }); - - cy.findByRole("button", { name: "Reset" }).click(); - - // Set up key backup - cy.get(".mx_Dialog").within(() => { - cy.findByRole("button", { name: "Continue" }).click(); - cy.get(".mx_CreateSecretStorageDialog_recoveryKey code").invoke("text").as("securityKey"); - // Clicking download instead of Copy because of https://github.com/cypress-io/cypress/issues/2851 - cy.findByRole("button", { name: "Download" }).click(); - cy.get(".mx_Dialog_primary:not([disabled])").should("have.length", 3); - cy.findByRole("button", { name: "Continue" }).click(); - cy.findByRole("button", { name: "Done" }).click(); - }); - - cy.get(".mx_DecryptionFailureBar_start_headline").within(() => { - cy.findByText("Some messages could not be decrypted").should("be.visible"); - }); - - checkTimelineNarrow(false); // button should not be rendered here - - cy.get(".mx_DecryptionFailureBar").percySnapshotElement( - "DecryptionFailureBar displays general message with no call to action", - { - widths: [320, 640], - }, - ); - }, - ); - - it("should appear and disappear as undecryptable messages enter and leave view", () => { - cy.getClient().then((cli) => { - for (let i = 0; i < 25; i++) { - cy.botSendMessage(cli, roomId, `test ${i}`); - } - }); - cy.botSendMessage(bot, roomId, "test"); - cy.get(".mx_DecryptionFailureBar").should("exist"); - cy.get(".mx_DecryptionFailureBar .mx_Spinner").should("exist"); - - cy.get(".mx_DecryptionFailureBar").percySnapshotElement("DecryptionFailureBar displays loading spinner", { - allowSpinners: true, - widths: [320, 640], - }); - - checkTimelineNarrow(); - - cy.wait(5000); - cy.get(".mx_DecryptionFailureBar .mx_Spinner").should("not.exist"); - cy.findByTestId("decryption-failure-bar-icon").should("be.visible"); - - cy.get(".mx_RoomView_messagePanel").scrollTo("top"); - cy.get(".mx_DecryptionFailureBar").should("not.exist"); - - cy.botSendMessage(bot, roomId, "another test"); - cy.get(".mx_DecryptionFailureBar").should("not.exist"); - - cy.get(".mx_RoomView_messagePanel").scrollTo("bottom"); - cy.get(".mx_DecryptionFailureBar").should("exist"); - - checkTimelineNarrow(); - }); -}); diff --git a/cypress/e2e/crypto/utils.ts b/cypress/e2e/crypto/utils.ts deleted file mode 100644 index 3e91d1e93db..00000000000 --- a/cypress/e2e/crypto/utils.ts +++ /dev/null @@ -1,121 +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 { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; -import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; - -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) => { - // @ts-ignore CryptoEvent is not exported to window.matrixcs; using the string value here - cli.off("crypto.verification.request", onVerificationRequestEvent); - await request.accept(); - resolve(request); - }; - // @ts-ignore - cli.on("crypto.verification.request", onVerificationRequestEvent); - }); -} - -/** - * Automatically handle an incoming verification request - * - * Starts the key verification process, and, once it is accepted on the other side, confirms that the - * emojis match. - * - * @param request - incoming verification request - * @returns A promise that resolves, with the emoji list, once we confirm the emojis - */ -export function handleVerificationRequest(request: VerificationRequest): 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); - }; - - const verifier = request.beginKeyVerification("m.sas.v1"); - // @ts-ignore as above, avoiding reference to VerifierEvent - verifier.on("show_sas", onShowSas); - verifier.verify(); - }); -} - -/** - * 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; - }); -} - -/** - * Fill in the login form in element with the given creds - */ -export function logIntoElement(homeserverUrl: string, username: string, password: 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(); -} diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts deleted file mode 100644 index d42877e1b05..00000000000 --- a/cypress/e2e/editing/editing.spec.ts +++ /dev/null @@ -1,374 +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 { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; -import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; -import type { IContent } from "matrix-js-sdk/src/models/event"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import Chainable = Cypress.Chainable; - -const sendEvent = (roomId: string): Chainable => { - return cy.sendEvent(roomId, null, "m.room.message" as EventType, { - msgtype: "m.text" as MsgType, - body: "Message", - }); -}; - -/** generate a message event which will take up some room on the page. */ -function mkPadding(n: number): IContent { - return { - msgtype: "m.text" as MsgType, - body: `padding ${n}`, - format: "org.matrix.custom.html", - formatted_body: `

Test event ${n}

\n`.repeat(10), - }; -} - -describe("Editing", () => { - let homeserver: HomeserverInstance; - let roomId: string; - - // Edit "Message" - const editLastMessage = (edit: string) => { - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Edit" }).click(); - cy.findByRole("textbox", { name: "Edit message" }).type(`{selectAll}{del}${edit}{enter}`); - }; - - const clickEditedMessage = (edited: string) => { - // Assert that the message was edited - cy.contains(".mx_EventTile", edited) - .should("exist") - .within(() => { - // Click to display the message edit history dialog - cy.contains(".mx_EventTile_edited", "(edited)").click(); - }); - }; - - const clickButtonViewSource = () => { - // Assert that "View Source" button is rendered and click it - cy.get(".mx_EventTile .mx_EventTile_line").realHover().findByRole("button", { name: "View Source" }).click(); - }; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Edith").then(() => { - cy.createRoom({ name: "Test room" }).then((_room1Id) => { - roomId = _room1Id; - }), - cy.injectAxe(); - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should render and interact with the message edit history dialog", () => { - // Click the "Remove" button on the message edit history dialog - const clickButtonRemove = () => { - cy.get(".mx_EventTile_line").realHover().findByRole("button", { name: "Remove" }).click(); - }; - - cy.visit("/#/room/" + roomId); - - // Send "Message" - sendEvent(roomId); - - cy.get(".mx_RoomView_MessageList").within(() => { - // Edit "Message" to "Massage" - editLastMessage("Massage"); - - // Assert that the edit label is visible - cy.get(".mx_EventTile_edited").should("be.visible"); - - clickEditedMessage("Massage"); - }); - - cy.get(".mx_Dialog").within(() => { - // Assert that the message edit history dialog is rendered - cy.get(".mx_MessageEditHistoryDialog").within(() => { - // Assert CSS styles which are difficult or cannot be detected with snapshots are applied as expected - cy.get("li").should("have.css", "clear", "both"); - cy.get(".mx_EventTile .mx_MessageTimestamp") - .should("have.css", "position", "absolute") - .should("have.css", "inset-inline-start", "0px") - .should("have.css", "text-align", "center"); - // Assert that monospace characters can fill the content line as expected - cy.get(".mx_EventTile .mx_EventTile_content").should("have.css", "margin-inline-end", "0px"); - - // Assert that zero block start padding is applied to mx_EventTile as expected - // See: .mx_EventTile on _EventTile.pcss - cy.get(".mx_EventTile").should("have.css", "padding-block-start", "0px"); - - // Assert that the date separator is rendered at the top - cy.get("li:nth-child(1) .mx_DateSeparator").within(() => { - cy.get("h2").within(() => { - cy.findByText("Today"); - }); - }); - - // Assert that the edited message is rendered under the date separator - cy.get("li:nth-child(2) .mx_EventTile").within(() => { - // Assert that the edited message body consists of both deleted character and inserted character - // Above the first "e" of "Message" was replaced with "a" - cy.get(".mx_EventTile_content .mx_EventTile_body").should("have.text", "Meassage"); - - cy.get(".mx_EventTile_content .mx_EventTile_body").within(() => { - cy.get(".mx_EditHistoryMessage_deletion").within(() => { - cy.findByText("e"); - }); - cy.get(".mx_EditHistoryMessage_insertion").within(() => { - cy.findByText("a"); - }); - }); - }); - - // Assert that the original message is rendered at the bottom - cy.get("li:nth-child(3) .mx_EventTile").within(() => { - cy.get(".mx_EventTile_content .mx_EventTile_body").within(() => { - cy.findByText("Message"); - }); - }); - }); - }); - - // Exclude timestamps from a snapshot - const percyCSS = ".mx_MessageTimestamp { visibility: hidden !important; }"; - - // Take a snapshot of the dialog - cy.get(".mx_Dialog_wrapper").percySnapshotElement("Message edit history dialog", { percyCSS }); - - cy.get(".mx_Dialog").within(() => { - cy.get(".mx_MessageEditHistoryDialog li:nth-child(2) .mx_EventTile").within(() => { - cy.get(".mx_EventTile_content .mx_EventTile_body").should("have.text", "Meassage"); - - // Click the "Remove" button again - clickButtonRemove(); - }); - - // Do nothing and close the dialog to confirm that the message edit history dialog is rendered - cy.get(".mx_TextInputDialog").closeDialog(); - - // Assert that the message edit history dialog is rendered again after it was closed - cy.get(".mx_MessageEditHistoryDialog li:nth-child(2) .mx_EventTile").within(() => { - cy.get(".mx_EventTile_content .mx_EventTile_body").should("have.text", "Meassage"); - - // Click the "Remove" button again - clickButtonRemove(); - }); - - // This time remove the message really - cy.get(".mx_TextInputDialog").within(() => { - cy.findByRole("textbox", { name: "Reason (optional)" }).type("This is a test."); // Reason - cy.findByRole("button", { name: "Remove" }).click(); - }); - - // Assert that the message edit history dialog is rendered again - cy.get(".mx_MessageEditHistoryDialog").within(() => { - // Assert that the date is rendered - cy.get("li:nth-child(1) .mx_DateSeparator").within(() => { - cy.get("h2").within(() => { - cy.findByText("Today"); - }); - }); - - // Assert that the original message is rendered under the date on the dialog - cy.get("li:nth-child(2) .mx_EventTile").within(() => { - cy.get(".mx_EventTile_content .mx_EventTile_body").within(() => { - cy.findByText("Message"); - }); - }); - - // Assert that the edited message is gone - cy.contains(".mx_EventTile_content .mx_EventTile_body", "Meassage").should("not.exist"); - - cy.closeDialog(); - }); - }); - - // Assert that the main timeline is rendered - cy.get(".mx_RoomView_MessageList").within(() => { - cy.get(".mx_EventTile_last .mx_RedactedBody").within(() => { - // Assert that the placeholder is rendered - cy.findByText("Message deleted"); - }); - }); - }); - - it("should render 'View Source' button in developer mode on the message edit history dialog", () => { - cy.visit("/#/room/" + roomId); - - // Send "Message" - sendEvent(roomId); - - cy.get(".mx_RoomView_MessageList").within(() => { - // Edit "Message" to "Massage" - editLastMessage("Massage"); - - // Assert that the edit label is visible - cy.get(".mx_EventTile_edited").should("be.visible"); - - clickEditedMessage("Massage"); - }); - - cy.get(".mx_Dialog").within(() => { - // Assert that the original message is rendered - cy.get(".mx_MessageEditHistoryDialog li:nth-child(3)").within(() => { - // Assert that "View Source" is not rendered - cy.get(".mx_EventTile .mx_EventTile_line") - .realHover() - .findByRole("button", { name: "View Source" }) - .should("not.exist"); - }); - - cy.closeDialog(); - }); - - // Enable developer mode - cy.setSettingValue("developerMode", null, SettingLevel.ACCOUNT, true); - - cy.get(".mx_RoomView_MessageList").within(() => { - clickEditedMessage("Massage"); - }); - - cy.get(".mx_Dialog").within(() => { - // Assert that the edited message is rendered - cy.get(".mx_MessageEditHistoryDialog li:nth-child(2)").within(() => { - // Assert that "Remove" button for the original message is rendered - cy.get(".mx_EventTile .mx_EventTile_line").realHover().findByRole("button", { name: "Remove" }); - - clickButtonViewSource(); - }); - - // Assert that view source dialog is rendered and close the dialog - cy.get(".mx_ViewSource").closeDialog(); - - // Assert that the original message is rendered - cy.get(".mx_MessageEditHistoryDialog li:nth-child(3)").within(() => { - // Assert that "Remove" button for the original message does not exist - cy.get(".mx_EventTile .mx_EventTile_line") - .realHover() - .findByRole("button", { name: "Remove" }) - .should("not.exist"); - - clickButtonViewSource(); - }); - - // Assert that view source dialog is rendered and close the dialog - cy.get(".mx_ViewSource").closeDialog(); - }); - }); - - it("should close the composer when clicking save after making a change and undoing it", () => { - cy.visit("/#/room/" + roomId); - - sendEvent(roomId); - - // Edit message - cy.get(".mx_RoomView_body .mx_EventTile").within(() => { - cy.findByText("Message"); - cy.get(".mx_EventTile_line").realHover().findByRole("button", { name: "Edit" }).click().checkA11y(); - cy.get(".mx_EventTile_line") - .findByRole("textbox", { name: "Edit message" }) - .type("Foo{backspace}{backspace}{backspace}{enter}") - .checkA11y(); - }); - cy.get(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]").within(() => { - cy.findByText("Message"); - }); - - // Assert that the edit composer has gone away - cy.findByRole("textbox", { name: "Edit message" }).should("not.exist"); - }); - - it("should correctly display events which are edited, where we lack the edit event", () => { - // This tests the behaviour when a message has been edited some time after it has been sent, and we - // jump back in room history to view the event, but do not have the actual edit event. - // - // In that scenario, we rely on the server to replace the content (pre-MSC3925), or do it ourselves based on - // the bundled edit event (post-MSC3925). - // - // To test it, we need to have a room with lots of events in, so we can jump around the timeline without - // paginating in the event itself. Hence, we create a bot user which creates the room and populates it before - // we join. - - let testRoomId: string; - let originalEventId: string; - let editEventId: string; - - // create a second user - const bobChainable = cy.getBot(homeserver, { displayName: "Bob", userIdPrefix: "bob_" }); - - cy.all([cy.window({ log: false }), bobChainable]).then(async ([win, bob]) => { - // "bob" now creates the room, and sends a load of events in it. Note that all of this happens via calls on - // the js-sdk rather than Cypress commands, so uses regular async/await. - - const room = await bob.createRoom({ name: "TestRoom", visibility: win.matrixcs.Visibility.Public }); - testRoomId = room.room_id; - cy.log(`Bot user created room ${room.room_id}`); - - originalEventId = (await bob.sendMessage(room.room_id, { body: "original", msgtype: "m.text" })).event_id; - cy.log(`Bot user sent original event ${originalEventId}`); - - // send a load of padding events. We make them large, so that they fill the whole screen - // and the client doesn't end up paginating into the event we want. - let i = 0; - while (i < 10) { - await bob.sendMessage(room.room_id, mkPadding(i++)); - } - - // ... then the edit ... - editEventId = ( - await bob.sendMessage(room.room_id, { - "m.new_content": { body: "Edited body", msgtype: "m.text" }, - "m.relates_to": { - rel_type: "m.replace", - event_id: originalEventId, - }, - "body": "* edited", - "msgtype": "m.text", - }) - ).event_id; - cy.log(`Bot user sent edit event ${editEventId}`); - - // ... then a load more padding ... - while (i < 20) { - await bob.sendMessage(room.room_id, mkPadding(i++)); - } - }); - - cy.getClient().then((cli) => { - // now have the cypress user join the room, jump to the original event, and wait for the event to be - // visible - cy.joinRoom(testRoomId); - cy.viewRoomByName("TestRoom"); - cy.visit(`#/room/${testRoomId}/${originalEventId}`); - cy.get(`[data-event-id="${originalEventId}"]`).should((messageTile) => { - // at this point, the edit event should still be unknown - expect(cli.getRoom(testRoomId).getTimelineForEvent(editEventId)).to.be.null; - - // nevertheless, the event should be updated - expect(messageTile.find(".mx_EventTile_body").text()).to.eq("Edited body"); - expect(messageTile.find(".mx_EventTile_edited")).to.exist; - }); - }); - }); -}); 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 b2dcb9146ae..00000000000 --- 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 7075c1c199f..00000000000 --- 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 65b195a3c72..00000000000 --- 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 d8a746b4237..00000000000 --- 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/invite/invite-dialog.spec.ts b/cypress/e2e/invite/invite-dialog.spec.ts deleted file mode 100644 index 80edfa411d6..00000000000 --- a/cypress/e2e/invite/invite-dialog.spec.ts +++ /dev/null @@ -1,184 +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 type { MatrixClient } from "matrix-js-sdk/src/client"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; - -describe("Invite dialog", function () { - let homeserver: HomeserverInstance; - let bot: MatrixClient; - const botName = "BotAlice"; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Hanako"); - - cy.getBot(homeserver, { displayName: botName, autoAcceptInvites: true }).then((_bot) => { - bot = _bot; - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should support inviting a user to a room", () => { - // Create and view a room - cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room"); - - // Assert that the room was configured - cy.findByText("Hanako created and configured the room.").should("exist"); - - // Open the room info panel - cy.findByRole("button", { name: "Room info" }).click(); - - cy.get(".mx_RightPanel").within(() => { - // Click "People" button on the panel - // Regex pattern due to the string of "mx_BaseCard_Button_sublabel" - cy.findByRole("button", { name: /People/ }).click(); - }); - - cy.get(".mx_BaseCard_header").within(() => { - // Click "Invite to this room" button - // Regex pattern due to "mx_MemberList_invite span::before" - cy.findByRole("button", { name: /Invite to this room/ }).click(); - }); - - cy.get(".mx_InviteDialog_other").within(() => { - cy.get(".mx_Dialog_header .mx_Dialog_title").within(() => { - // Assert that the header is rendered - cy.findByText("Invite to Test Room").should("exist"); - }); - - // Assert that the bar is rendered - cy.get(".mx_InviteDialog_addressBar").should("exist"); - }); - - // TODO: unhide userId - const percyCSS = ".mx_InviteDialog_helpText_userId { visibility: hidden !important; }"; - - // Take a snapshot of the invite dialog including its wrapper - cy.get(".mx_Dialog_wrapper").percySnapshotElement("Invite Dialog - Room (without a user)", { percyCSS }); - - cy.get(".mx_InviteDialog_other").within(() => { - cy.get(".mx_InviteDialog_identityServer").should("not.exist"); - - cy.findByTestId("invite-dialog-input").type(bot.getUserId()); - - // Assert that notification about identity servers appears after typing userId - cy.get(".mx_InviteDialog_identityServer").should("exist"); - - cy.get(".mx_InviteDialog_tile_nameStack").within(() => { - cy.get(".mx_InviteDialog_tile_nameStack_userId").within(() => { - // Assert that the bot id is rendered properly - cy.findByText(bot.getUserId()).should("exist"); - }); - - cy.get(".mx_InviteDialog_tile_nameStack_name").within(() => { - cy.findByText(botName).click(); - }); - }); - - cy.get(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").within(() => { - cy.findByText(botName).should("exist"); - }); - }); - - // Take a snapshot of the invite dialog with a user pill - cy.get(".mx_Dialog_wrapper").percySnapshotElement("Invite Dialog - Room (with a user pill)", { percyCSS }); - - cy.get(".mx_InviteDialog_other").within(() => { - // Invite the bot - cy.findByRole("button", { name: "Invite" }).click(); - }); - - // Assert that the invite dialog disappears - cy.get(".mx_InviteDialog_other").should("not.exist"); - - // Assert that they were invited and joined - cy.findByText(`${botName} joined the room`).should("exist"); - }); - - it("should support inviting a user to Direct Messages", () => { - cy.get(".mx_RoomList").within(() => { - cy.findByRole("button", { name: "Start chat" }).click(); - }); - - cy.get(".mx_InviteDialog_other").within(() => { - cy.get(".mx_Dialog_header .mx_Dialog_title").within(() => { - // Assert that the header is rendered - cy.findByText("Direct Messages").should("exist"); - }); - - // Assert that the bar is rendered - cy.get(".mx_InviteDialog_addressBar").should("exist"); - }); - - // TODO: unhide userId and invite link - const percyCSS = - ".mx_InviteDialog_footer_link, .mx_InviteDialog_helpText_userId { visibility: hidden !important; }"; - - // Take a snapshot of the invite dialog including its wrapper - cy.get(".mx_Dialog_wrapper").percySnapshotElement("Invite Dialog - Direct Messages (without a user)", { - percyCSS, - }); - - cy.get(".mx_InviteDialog_other").within(() => { - cy.findByTestId("invite-dialog-input").type(bot.getUserId()); - - cy.get(".mx_InviteDialog_tile_nameStack").within(() => { - cy.findByText(bot.getUserId()).should("exist"); - cy.findByText(botName).click(); - }); - - cy.get(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").within(() => { - cy.findByText(botName).should("exist"); - }); - }); - - // Take a snapshot of the invite dialog with a user pill - cy.get(".mx_Dialog_wrapper").percySnapshotElement("Invite Dialog - Direct Messages (with a user pill)", { - percyCSS, - }); - - cy.get(".mx_InviteDialog_other").within(() => { - // Open a direct message UI - cy.findByRole("button", { name: "Go" }).click(); - }); - - // Assert that the invite dialog disappears - cy.get(".mx_InviteDialog_other").should("not.exist"); - - // Assert that the hovered user name on invitation UI does not have background color - // TODO: implement the test on room-header.spec.ts - cy.get(".mx_RoomHeader").within(() => { - cy.get(".mx_RoomHeader_name--textonly") - .realHover() - .should("have.css", "background-color", "rgba(0, 0, 0, 0)"); - }); - - // Send a message to invite the bots - cy.getComposer().type("Hello{enter}"); - - // Assert that they were invited and joined - cy.findByText(`${botName} joined the room`).should("exist"); - - // Assert that the message is displayed at the bottom - cy.get(".mx_EventTile_last").findByText("Hello").should("exist"); - }); -}); 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 05bed5cf682..00000000000 --- 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_RoomHeader").within(() => { - cy.findByRole("button", { name: "Room info" }).click(); - }); - - cy.get(".mx_RoomSummaryCard").within(() => { - cy.findByRole("button", { name: /People \d/ }).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/location/location.spec.ts b/cypress/e2e/location/location.spec.ts deleted file mode 100644 index 6588cde1a49..00000000000 --- a/cypress/e2e/location/location.spec.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 { HomeserverInstance } from "../../plugins/utils/homeserver"; -import Chainable = Cypress.Chainable; - -describe("Location sharing", () => { - let homeserver: HomeserverInstance; - - const selectLocationShareTypeOption = (shareType: string): Chainable => { - return cy.findByTestId(`share-location-option-${shareType}`); - }; - - const submitShareLocation = (): void => { - cy.findByRole("button", { name: "Share location" }).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("sends and displays pin drop location message successfully", () => { - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.visit("/#/room/" + roomId); - }); - - cy.openMessageComposerOptions().within(() => { - cy.findByRole("menuitem", { name: "Location" }).click(); - }); - - selectLocationShareTypeOption("Pin").click(); - - cy.get("#mx_LocationPicker_map").click("center"); - - submitShareLocation(); - - cy.get(".mx_RoomView_body .mx_EventTile .mx_MLocationBody", { timeout: 10000 }).should("exist").click(); - - // clicking location tile opens maximised map - cy.get(".mx_LocationViewDialog_wrapper").should("exist"); - - cy.closeDialog(); - - cy.get(".mx_Marker").should("exist"); - }); -}); diff --git a/cypress/e2e/login/consent.spec.ts b/cypress/e2e/login/consent.spec.ts deleted file mode 100644 index 32f4e0f0338..00000000000 --- a/cypress/e2e/login/consent.spec.ts +++ /dev/null @@ -1,75 +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 { SinonStub } from "cypress/types/sinon"; - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; - -describe("Consent", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("consent").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Bob"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should prompt the user to consent to terms when server deems it necessary", () => { - // Attempt to create a room using the js-sdk which should return an error with `M_CONSENT_NOT_GIVEN` - cy.window().then((win) => { - win.mxMatrixClientPeg.matrixClient.createRoom({}).catch(() => {}); - - // Stub `window.open` - clicking the primary button below will call it - cy.stub(win, "open").as("windowOpen").returns({}); - }); - - // Accept terms & conditions - cy.get(".mx_QuestionDialog").within(() => { - cy.get("#mx_BaseDialog_title").within(() => { - cy.findByText("Terms and Conditions"); - }); - cy.findByRole("button", { name: "Review terms and conditions" }).click(); - }); - - cy.get("@windowOpen").then((stub) => { - const url = stub.getCall(0).args[0]; - - // Go to Homeserver's consent page and accept it - cy.origin(homeserver.baseUrl, { args: { url } }, ({ url }) => { - cy.visit(url); - - cy.get('[type="submit"]').click(); - cy.contains("p", "Danke schoen"); - }); - }); - - // go back to the app - cy.visit("/"); - // wait for the app to re-load - cy.get(".mx_MatrixChat", { timeout: 15000 }); - - // attempt to perform the same action again and expect it to not fail - cy.createRoom({}); - }); -}); diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts deleted file mode 100644 index 9bc6dd3f1b2..00000000000 --- a/cypress/e2e/login/login.spec.ts +++ /dev/null @@ -1,106 +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"; - -describe("Login", () => { - let homeserver: HomeserverInstance; - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - describe("m.login.password", () => { - const username = "user1234"; - const password = "p4s5W0rD"; - - beforeEach(() => { - cy.startHomeserver("consent").then((data) => { - homeserver = data; - cy.registerUser(homeserver, username, password); - cy.visit("/#/login"); - }); - }); - - it("logs in with an existing account and lands on the home screen", () => { - cy.injectAxe(); - - // first pick the homeserver, as otherwise the user picker won't be visible - cy.findByRole("button", { name: "Edit" }).click(); - cy.findByRole("textbox", { name: "Other homeserver" }).type(homeserver.baseUrl); - 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", timeout: 15000 }).should("be.visible"); - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 - //cy.percySnapshot("Login"); - cy.checkA11y(); - - cy.findByRole("textbox", { name: "Username" }).type(username); - cy.findByPlaceholderText("Password").type(password); - cy.findByRole("button", { name: "Sign in" }).click(); - - cy.url().should("contain", "/#/home", { timeout: 30000 }); - }); - }); - - describe("logout", () => { - beforeEach(() => { - cy.startHomeserver("consent").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Erin"); - }); - }); - - it("should go to login page on logout", () => { - cy.findByRole("button", { name: "User menu" }).click(); - - // give a change for the outstanding requests queue to settle before logging out - cy.wait(2000); - - cy.get(".mx_UserMenu_contextMenu").within(() => { - cy.findByRole("menuitem", { name: "Sign out" }).click(); - }); - - cy.url().should("contain", "/#/login"); - }); - - it("should respect logout_redirect_url", () => { - cy.tweakConfig({ - // We redirect to decoder-ring because it's a predictable page that isn't Element itself. - // We could use example.org, matrix.org, or something else, however this puts dependency of external - // infrastructure on our tests. In the same vein, we don't really want to figure out how to ship a - // `test-landing.html` page when running with an uncontrolled Element (via `yarn start`). - // Using the decoder-ring is just as fine, and we can search for strategic names. - logout_redirect_url: "/decoder-ring/", - }); - - cy.findByRole("button", { name: "User menu" }).click(); - - // give a change for the outstanding requests queue to settle before logging out - cy.wait(2000); - - cy.get(".mx_UserMenu_contextMenu").within(() => { - cy.findByRole("menuitem", { name: "Sign out" }).click(); - }); - - cy.url().should("contains", "decoder-ring"); - }); - }); -}); diff --git a/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts deleted file mode 100644 index 09bb5a33583..00000000000 --- a/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* -Copyright 2023 Ahmad Kadri -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 { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { Credentials } from "../../support/homeserver"; - -describe("1:1 chat room", () => { - let homeserver: HomeserverInstance; - let user2: Credentials; - - const username = "user1234"; - const password = "p4s5W0rD"; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Jeff"); - cy.registerUser(homeserver, username, password).then((credential) => { - user2 = credential; - cy.visit(`/#/user/${user2.userId}?action=chat`); - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should open new 1:1 chat room after leaving the old one", () => { - // leave 1:1 chat room - cy.get(".mx_RoomHeader_nametext").within(() => { - cy.findByText(username).click(); - }); - cy.findByRole("menuitem", { name: "Leave" }).click(); - cy.findByRole("button", { name: "Leave" }).click(); - - // wait till the room was left - cy.findByRole("group", { name: "Historical" }).within(() => { - cy.get(".mx_RoomTile").within(() => { - cy.findByText(username); - }); - }); - - // open new 1:1 chat room - cy.visit(`/#/user/${user2.userId}?action=chat`); - cy.get(".mx_RoomHeader_nametext").within(() => { - cy.findByText(username); - }); - }); -}); diff --git a/cypress/e2e/permalinks/permalinks.spec.ts b/cypress/e2e/permalinks/permalinks.spec.ts deleted file mode 100644 index 2a61df26a09..00000000000 --- 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 00938ab768b..00000000000 --- 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("button", { 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 1a6682a6429..00000000000 --- 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/read-receipts/read-receipts.spec.ts b/cypress/e2e/read-receipts/read-receipts.spec.ts deleted file mode 100644 index a36132a408e..00000000000 --- a/cypress/e2e/read-receipts/read-receipts.spec.ts +++ /dev/null @@ -1,354 +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 { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; -import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; -import type { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; - -describe("Read receipts", () => { - const userName = "Mae"; - const botName = "Other User"; - const selectedRoomName = "Selected Room"; - const otherRoomName = "Other Room"; - - let homeserver: HomeserverInstance; - let otherRoomId: string; - let selectedRoomId: string; - let bot: MatrixClient | undefined; - - const botSendMessage = (no = 1): Cypress.Chainable => { - return cy.botSendMessage(bot, otherRoomId, `Message ${no}`); - }; - - const botSendThreadMessage = (threadId: string): Cypress.Chainable => { - return cy.botSendThreadMessage(bot, otherRoomId, threadId, "Message"); - }; - - const fakeEventFromSent = (eventResponse: ISendEventResponse, threadRootId: string | undefined): MatrixEvent => { - return { - getRoomId: () => otherRoomId, - getId: () => eventResponse.event_id, - threadRootId, - getTs: () => 1, - } as any as MatrixEvent; - }; - - /** - * Send a threaded receipt marking the message referred to in - * eventResponse as read. If threadRootEventResponse is supplied, the - * receipt will have its event_id as the thread root ID for the receipt. - */ - const sendThreadedReadReceipt = ( - eventResponse: ISendEventResponse, - threadRootEventResponse: ISendEventResponse = undefined, - ) => { - cy.sendReadReceipt(fakeEventFromSent(eventResponse, threadRootEventResponse?.event_id)); - }; - - /** - * Send an unthreaded receipt marking the message referred to in - * eventResponse as read. - */ - const sendUnthreadedReadReceipt = (eventResponse: ISendEventResponse) => { - cy.sendReadReceipt(fakeEventFromSent(eventResponse, undefined), "m.read" as any as ReceiptType, true); - }; - - beforeEach(() => { - /* - * Create 2 rooms: - * - * - Selected room - this one is clicked in the UI - * - Other room - this one contains the bot, which will send events so - * we can check its unread state. - */ - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, userName) - .then(() => { - cy.createRoom({ name: selectedRoomName }).then((createdRoomId) => { - selectedRoomId = createdRoomId; - }); - }) - .then(() => { - cy.createRoom({ name: otherRoomName }).then((createdRoomId) => { - otherRoomId = createdRoomId; - }); - }) - .then(() => { - cy.getBot(homeserver, { displayName: botName }).then((botClient) => { - bot = botClient; - }); - }) - .then(() => { - // Invite the bot to Other room - cy.inviteUser(otherRoomId, bot.getUserId()); - cy.visit("/#/room/" + otherRoomId); - cy.findByText(botName + " joined the room").should("exist"); - - // Then go into Selected room - cy.visit("/#/room/" + selectedRoomId); - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it( - "With sync accumulator, considers main thread and unthreaded receipts #24629", - { - // When #24629 exists, the test fails the first time but passes later, so we disable retries - // to be sure we are going to fail if the bug comes back. - // Why does it pass the second time? I wish I knew. (andyb) - retries: 0, - }, - () => { - // Details are in https://github.com/vector-im/element-web/issues/24629 - // This proves we've fixed one of the "stuck unreads" issues. - - // Given we sent 3 events on the main thread - botSendMessage(); - botSendMessage().then((main2) => { - botSendMessage().then((main3) => { - // (So the room starts off unread) - cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist"); - - // When we send a threaded receipt for the last event in main - // And an unthreaded receipt for an earlier event - sendThreadedReadReceipt(main3); - sendUnthreadedReadReceipt(main2); - - // (So the room has no unreads) - cy.findByLabelText(`${otherRoomName}`).should("exist"); - - // And we persuade the app to persist its state to indexeddb by reloading and waiting - cy.reload(); - cy.findByLabelText(`${selectedRoomName}`).should("exist"); - - // And we reload again, fetching the persisted state FROM indexeddb - cy.reload(); - - // Then the room is read, because the persisted state correctly remembers both - // receipts. (In #24629, the unthreaded receipt overwrote the main thread one, - // meaning that the room still said it had unread messages.) - cy.findByLabelText(`${otherRoomName}`).should("exist"); - cy.findByLabelText(`${otherRoomName} Unread messages.`).should("not.exist"); - }); - }); - }, - ); - - it("Recognises unread messages on main thread after receiving a receipt for earlier ones", () => { - // Given we sent 3 events on the main thread - botSendMessage(); - botSendMessage().then((main2) => { - botSendMessage().then(() => { - // (The room starts off unread) - cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist"); - - // When we send a threaded receipt for the second-last event in main - sendThreadedReadReceipt(main2); - - // Then the room has only one unread - cy.findByLabelText(`${otherRoomName} 1 unread message.`).should("exist"); - }); - }); - }); - - it("Considers room read if there is only a main thread and we have a main receipt", () => { - // Given we sent 3 events on the main thread - botSendMessage(); - botSendMessage().then(() => { - botSendMessage().then((main3) => { - // (The room starts off unread) - cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist"); - - // When we send a threaded receipt for the last event in main - sendThreadedReadReceipt(main3); - - // Then the room has no unreads - cy.findByLabelText(`${otherRoomName}`).should("exist"); - }); - }); - }); - - it("Recognises unread messages on other thread after receiving a receipt for earlier ones", () => { - // Given we sent 3 events on the main thread - botSendMessage().then((main1) => { - botSendThreadMessage(main1.event_id).then((thread1a) => { - botSendThreadMessage(main1.event_id).then((thread1b) => { - // 1 unread on the main thread, 2 in the new thread - cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist"); - - // When we send receipts for main, and the second-last in the thread - sendThreadedReadReceipt(main1); - sendThreadedReadReceipt(thread1a, main1); - - // Then the room has only one unread - the one in the thread - cy.findByLabelText(`${otherRoomName} 1 unread message.`).should("exist"); - }); - }); - }); - }); - - it("Considers room read if there are receipts for main and other thread", () => { - // Given we sent 3 events on the main thread - botSendMessage().then((main1) => { - botSendThreadMessage(main1.event_id).then((thread1a) => { - botSendThreadMessage(main1.event_id).then((thread1b) => { - // 1 unread on the main thread, 2 in the new thread - cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist"); - - // When we send receipts for main, and the last in the thread - sendThreadedReadReceipt(main1); - sendThreadedReadReceipt(thread1b, main1); - - // Then the room has no unreads - cy.findByLabelText(`${otherRoomName}`).should("exist"); - }); - }); - }); - }); - - it("Recognises unread messages on a thread after receiving a unthreaded receipt for earlier ones", () => { - // Given we sent 3 events on the main thread - botSendMessage().then((main1) => { - botSendThreadMessage(main1.event_id).then((thread1a) => { - botSendThreadMessage(main1.event_id).then(() => { - // 1 unread on the main thread, 2 in the new thread - cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist"); - - // When we send an unthreaded receipt for the second-last in the thread - sendUnthreadedReadReceipt(thread1a); - - // Then the room has only one unread - the one in the - // thread. The one in main is read because the unthreaded - // receipt is for a later event. - cy.findByLabelText(`${otherRoomName} 1 unread message.`).should("exist"); - }); - }); - }); - }); - - it("Recognises unread messages on main after receiving a unthreaded receipt for a thread message", () => { - // Given we sent 3 events on the main thread - botSendMessage().then((main1) => { - botSendThreadMessage(main1.event_id).then(() => { - botSendThreadMessage(main1.event_id).then((thread1b) => { - botSendMessage().then(() => { - // 2 unreads on the main thread, 2 in the new thread - cy.findByLabelText(`${otherRoomName} 4 unread messages.`).should("exist"); - - // When we send an unthreaded receipt for the last in the thread - sendUnthreadedReadReceipt(thread1b); - - // Then the room has only one unread - the one in the - // main thread, because it is later than the unthreaded - // receipt. - cy.findByLabelText(`${otherRoomName} 1 unread message.`).should("exist"); - }); - }); - }); - }); - }); - - /** - * The idea of this test is to intercept the receipt / read read_markers requests and - * assert that the correct ones are sent. - * Prose playbook: - * - Another user sends enough messages that the timeline becomes scrollable - * - The current user looks at the room and jumps directly to the first unread message - * - At this point, a receipt for the last message in the room and - * a fully read marker for the last visible message are expected to be sent - * - Then the user jumps to the end of the timeline - * - A fully read marker for the last message in the room is expected to be sent - */ - it("Should send the correct receipts", () => { - const uriEncodedOtherRoomId = encodeURIComponent(otherRoomId); - - cy.intercept({ - method: "POST", - url: new RegExp( - `http://localhost:\\d+/_matrix/client/r0/rooms/${uriEncodedOtherRoomId}/receipt/m\\.read/.+`, - ), - }).as("receiptRequest"); - - const numberOfMessages = 20; - const sendMessagePromises = []; - - for (let i = 1; i <= numberOfMessages; i++) { - sendMessagePromises.push(botSendMessage(i)); - } - - cy.all(sendMessagePromises).then((sendMessageResponses) => { - const lastMessageId = sendMessageResponses.at(-1).event_id; - const uriEncodedLastMessageId = encodeURIComponent(lastMessageId); - - // wait until all messages have been received - cy.findByLabelText(`${otherRoomName} ${sendMessagePromises.length} unread messages.`).should("exist"); - - // switch to the room with the messages - cy.visit("/#/room/" + otherRoomId); - - cy.wait("@receiptRequest").should((req) => { - // assert the read receipt for the last message in the room - expect(req.request.url).to.contain(uriEncodedLastMessageId); - expect(req.request.body).to.deep.equal({ - thread_id: "main", - }); - }); - - // the following code tests the fully read marker somewhere in the middle of the room - - cy.intercept({ - method: "POST", - url: new RegExp(`http://localhost:\\d+/_matrix/client/r0/rooms/${uriEncodedOtherRoomId}/read_markers`), - }).as("readMarkersRequest"); - - cy.findByRole("button", { name: "Jump to first unread message." }).click(); - - cy.wait("@readMarkersRequest").should((req) => { - // since this is not pixel perfect, - // the fully read marker should be +/- 1 around the last visible message - expect(Array.from(Object.keys(req.request.body))).to.deep.equal(["m.fully_read"]); - expect(req.request.body["m.fully_read"]).to.be.oneOf([ - sendMessageResponses[11].event_id, - sendMessageResponses[12].event_id, - sendMessageResponses[13].event_id, - ]); - }); - - // the following code tests the fully read marker at the bottom of the room - - cy.intercept({ - method: "POST", - url: new RegExp(`http://localhost:\\d+/_matrix/client/r0/rooms/${uriEncodedOtherRoomId}/read_markers`), - }).as("readMarkersRequest"); - - cy.findByRole("button", { name: "Scroll to most recent messages" }).click(); - - cy.wait("@readMarkersRequest").should((req) => { - expect(req.request.body).to.deep.equal({ - ["m.fully_read"]: sendMessageResponses.at(-1).event_id, - }); - }); - }); - }); -}); diff --git a/cypress/e2e/register/register.spec.ts b/cypress/e2e/register/register.spec.ts deleted file mode 100644 index 5810915439b..00000000000 --- a/cypress/e2e/register/register.spec.ts +++ /dev/null @@ -1,145 +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 { checkDeviceIsCrossSigned } from "../crypto/utils"; - -describe("Registration", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.visit("/#/register"); - cy.startHomeserver("consent").then((data) => { - homeserver = data; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("registers an account and lands on the home screen", () => { - cy.injectAxe(); - - cy.findByRole("button", { name: "Edit", timeout: 15000 }).click(); - cy.findByRole("button", { name: "Continue" }).should("be.visible"); - // Only snapshot the server picker otherwise in the background `matrix.org` may or may not be available - cy.get(".mx_Dialog").percySnapshotElement("Server Picker", { widths: [516] }); - cy.checkA11y(); - - cy.findByRole("textbox", { name: "Other homeserver" }).type(homeserver.baseUrl); - 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" }).should("be.visible"); - // Hide the server text as it contains the randomly allocated Homeserver port - const percyCSS = ".mx_ServerPicker_server { visibility: hidden !important; }"; - cy.percySnapshot("Registration", { percyCSS }); - cy.checkA11y(); - - cy.findByRole("textbox", { name: "Username" }).type("alice"); - cy.findByPlaceholderText("Password").type("totally a great password"); - cy.findByPlaceholderText("Confirm password").type("totally a great password"); - cy.findByRole("button", { name: "Register" }).click(); - - cy.get(".mx_RegistrationEmailPromptDialog").should("be.visible"); - cy.percySnapshot("Registration email prompt", { percyCSS }); - cy.checkA11y(); - cy.get(".mx_RegistrationEmailPromptDialog").within(() => { - cy.findByRole("button", { name: "Continue" }).click(); - }); - - cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy").should("be.visible"); - cy.percySnapshot("Registration terms prompt", { percyCSS }); - cy.checkA11y(); - - cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy").within(() => { - cy.findByRole("checkbox").click(); // Click the checkbox before privacy policy anchor link - cy.findByLabelText("Privacy Policy").should("be.visible"); - }); - - cy.findByRole("button", { name: "Accept" }).click(); - - cy.get(".mx_UseCaseSelection_skip", { timeout: 30000 }).should("exist"); - cy.percySnapshot("Use-case selection screen"); - cy.checkA11y(); - cy.findByRole("button", { name: "Skip" }).click(); - - cy.url().should("contain", "/#/home"); - - /* - * Cross-signing checks - */ - - // check that the device considers itself verified - cy.findByRole("button", { name: "User menu" }).click(); - cy.findByRole("menuitem", { name: "All settings" }).click(); - cy.findByRole("tab", { name: "Sessions" }).click(); - cy.findByTestId("current-session-section").within(() => { - cy.findByTestId("device-metadata-isVerified").should("have.text", "Verified"); - }); - - // check that cross-signing keys have been uploaded. - checkDeviceIsCrossSigned(); - }); - - it("should require username to fulfil requirements and be available", () => { - cy.findByRole("button", { name: "Edit", timeout: 15000 }).click(); - cy.findByRole("button", { name: "Continue" }).should("be.visible"); - cy.findByRole("textbox", { name: "Other homeserver" }).type(homeserver.baseUrl); - 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" }).should("be.visible"); - - cy.intercept("**/_matrix/client/*/register/available?username=_alice", { - statusCode: 400, - headers: { - "Content-Type": "application/json", - }, - body: { - errcode: "M_INVALID_USERNAME", - error: "User ID may not begin with _", - }, - }); - cy.findByRole("textbox", { name: "Username" }).type("_alice"); - cy.get(".mx_Field_tooltip") - .should("have.class", "mx_Tooltip_visible") - .should("contain.text", "Some characters not allowed"); - - cy.intercept("**/_matrix/client/*/register/available?username=bob", { - statusCode: 400, - headers: { - "Content-Type": "application/json", - }, - body: { - errcode: "M_USER_IN_USE", - error: "The desired username is already taken", - }, - }); - cy.findByRole("textbox", { name: "Username" }).type("{selectAll}{backspace}bob"); - cy.get(".mx_Field_tooltip") - .should("have.class", "mx_Tooltip_visible") - .should("contain.text", "Someone already has that username"); - - cy.findByRole("textbox", { name: "Username" }).type("{selectAll}{backspace}foobar"); - cy.get(".mx_Field_tooltip").should("not.have.class", "mx_Tooltip_visible"); - }); -}); diff --git a/cypress/e2e/regression-tests/pills-click-in-app.spec.ts b/cypress/e2e/regression-tests/pills-click-in-app.spec.ts deleted file mode 100644 index f8e607a4f21..00000000000 --- a/cypress/e2e/regression-tests/pills-click-in-app.spec.ts +++ /dev/null @@ -1,77 +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"; - -describe("Pills", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Sally"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should navigate clicks internally to the app", () => { - const messageRoom = "Send Messages Here"; - const targetLocalpart = "aliasssssssssssss"; - cy.createRoom({ - name: "Target", - room_alias_name: targetLocalpart, - }).as("targetRoomId"); - cy.createRoom({ - name: messageRoom, - }).as("messageRoomId"); - cy.all([cy.get("@targetRoomId"), cy.get("@messageRoomId")]).then( - ([targetRoomId, messageRoomId]) => { - // discard the target room ID - we don't need it - cy.viewRoomByName(messageRoom); - cy.url().should("contain", `/#/room/${messageRoomId}`); - - // send a message using the built-in room mention functionality (autocomplete) - cy.findByRole("textbox", { name: "Send a message…" }).type( - `Hello world! Join here: #${targetLocalpart.substring(0, 3)}`, - ); - cy.get(".mx_Autocomplete_Completion_title").click(); - cy.findByRole("button", { name: "Send message" }).click(); - - // find the pill in the timeline and click it - cy.get(".mx_EventTile_body .mx_Pill").click(); - - const localUrl = `/#/room/#${targetLocalpart}:`; - // verify we landed at a sane place - cy.url().should("contain", localUrl); - - cy.wait(250); // let the room list settle - - // go back to the message room and try to click on the pill text, as a user would - cy.viewRoomByName(messageRoom); - cy.get(".mx_EventTile_body .mx_Pill .mx_Pill_text") - .should("have.css", "pointer-events", "none") - .click({ force: true }); // force is to ensure we bypass pointer-events - cy.url().should("contain", localUrl); - }, - ); - }); -}); diff --git a/cypress/e2e/right-panel/file-panel.spec.ts b/cypress/e2e/right-panel/file-panel.spec.ts deleted file mode 100644 index b36edfb276f..00000000000 --- a/cypress/e2e/right-panel/file-panel.spec.ts +++ /dev/null @@ -1,276 +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 Chainable = Cypress.Chainable; - -const ROOM_NAME = "Test room"; -const NAME = "Alice"; - -const viewRoomSummaryByName = (name: string): Chainable> => { - cy.viewRoomByName(name); - cy.findByRole("button", { name: "Room info" }).click(); - return checkRoomSummaryCard(name); -}; - -const checkRoomSummaryCard = (name: string): Chainable> => { - cy.get(".mx_RoomSummaryCard").should("have.length", 1); - return cy.get(".mx_BaseCard_header").should("contain", name); -}; - -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(() => { - 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"); -}; - -describe("FilePanel", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, NAME).then(() => - cy.window({ log: false }).then(() => { - cy.createRoom({ name: ROOM_NAME }); - }), - ); - }); - - // Open the file panel - viewRoomSummaryByName(ROOM_NAME); - cy.get(".mx_RoomSummaryCard_icon_files").click(); - cy.get(".mx_FilePanel").should("have.length", 1); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - describe("render", () => { - it("should render empty state", () => { - // Wait until the information about the empty state is rendered - cy.get(".mx_FilePanel_empty").should("exist"); - - // Take a snapshot of RightPanel - fix https://github.com/vector-im/element-web/issues/25332 - cy.get(".mx_RightPanel").percySnapshotElement("File Panel - empty", { - widths: [264], // Emulate the UI. The value is based on minWidth specified on MainSplit.tsx - }); - }); - - it("should list tiles on the panel", () => { - // Upload multiple files - uploadFile("cypress/fixtures/riot.png"); // Image - uploadFile("cypress/fixtures/1sec.ogg"); // Audio - uploadFile("cypress/fixtures/matrix-org-client-versions.json"); // JSON - - cy.get(".mx_RoomView_body").within(() => { - // Assert that all of the file were uploaded and rendered - cy.get(".mx_EventTile[data-layout='group']").should("have.length", 3); - - // Assert that the image exists and has the alt string - cy.get(".mx_EventTile[data-layout='group'] img[alt='riot.png']").should("exist"); - - // Assert that the audio player is rendered - cy.get(".mx_EventTile[data-layout='group'] .mx_AudioPlayer_container").should("exist"); - - // Assert that the file button exists - cy.contains(".mx_EventTile_last[data-layout='group'] .mx_MFileBody", ".json").should("exist"); - }); - - // Assert that the file panel is opened inside mx_RightPanel and visible - cy.get(".mx_RightPanel .mx_FilePanel").should("be.visible"); - - cy.get(".mx_FilePanel").within(() => { - cy.get(".mx_RoomView_MessageList").within(() => { - // Assert that data-layout attribute is not applied to file tiles on the panel - cy.get(".mx_EventTile[data-layout]").should("not.exist"); - - // Assert that all of the file tiles are rendered - cy.get(".mx_EventTile").should("have.length", 3); - - // Assert that the download links are rendered - cy.get(".mx_MFileBody_download").should("have.length", 3); - - // Assert that the sender of the files is rendered on all of the tiles - cy.findAllByText(NAME).should("have.length", 3); - - // Detect the image file - cy.get(".mx_EventTile_mediaLine.mx_EventTile_image").within(() => { - // Assert that the image is specified as thumbnail and has the alt string - cy.get(".mx_MImageBody").within(() => { - cy.get("img[class='mx_MImageBody_thumbnail']").should("exist"); - cy.get("img[alt='riot.png']").should("exist"); - }); - }); - - // Detect the audio file - cy.get(".mx_EventTile_mediaLine .mx_MAudioBody").within(() => { - // Assert that the audio player is rendered - cy.get(".mx_AudioPlayer_container").within(() => { - // Assert that the play button is rendered - cy.findByRole("button", { name: "Play" }).should("exist"); - }); - }); - - // Detect the JSON file - // Assert that the tile is rendered as a button - cy.get(".mx_EventTile_mediaLine .mx_MFileBody .mx_MFileBody_info[role='button']").within(() => { - // Assert that the file name is rendered inside the button with ellipsis - cy.get(".mx_MFileBody_info_filename").within(() => { - cy.findByText(/matrix.*?\.json/); - }); - }); - }); - }); - - // Make the viewport tall enough to display all of the file tiles on FilePanel - cy.viewport(660, 1000); - - cy.get(".mx_FilePanel").within(() => { - // In case the panel is scrollable on the resized viewport - cy.get(".mx_ScrollPanel").scrollTo("bottom", { ensureScrollable: false }); - - // Assert that the value for flexbox is applied - cy.get(".mx_ScrollPanel .mx_RoomView_MessageList").should("have.css", "justify-content", "flex-end"); - - // Assert that all of the file tiles are visible before taking a snapshot - cy.get(".mx_RoomView_MessageList").within(() => { - cy.get(".mx_MImageBody").should("be.visible"); // top - cy.get(".mx_MAudioBody").should("be.visible"); // middle - cy.get(".mx_EventTile_last").within(() => { - // bottom - cy.get(".mx_EventTile_senderDetails").within(() => { - cy.get(".mx_DisambiguatedProfile").should("be.visible"); - cy.get(".mx_MessageTimestamp").should("be.visible"); - }); - }); - }); - }); - - // Exclude timestamps and read markers from snapshot - // FIXME: hide mx_SeekBar because flaky - see https://github.com/vector-im/element-web/issues/24897 - // Remove this once https://github.com/vector-im/element-web/issues/24898 is fixed. - const percyCSS = - ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .mx_SeekBar { visibility: hidden !important; }"; - - // Take a snapshot of file tiles list on FilePanel - cy.get(".mx_FilePanel .mx_RoomView_MessageList").percySnapshotElement("File tiles list on FilePanel", { - percyCSS, - widths: [250], // magic number, should be around the default width - }); - }); - - it("should render the audio pleyer and play the audio file on the panel", () => { - // Upload an image file - uploadFile("cypress/fixtures/1sec.ogg"); - - cy.get(".mx_FilePanel").within(() => { - cy.get(".mx_RoomView_MessageList").within(() => { - // Detect the audio file - cy.get(".mx_EventTile_mediaLine .mx_MAudioBody").within(() => { - // Assert that the audio player is rendered - cy.get(".mx_AudioPlayer_container").within(() => { - // Assert that the audio file information is rendered - cy.get(".mx_AudioPlayer_mediaInfo").within(() => { - cy.get(".mx_AudioPlayer_mediaName").within(() => { - cy.findByText("1sec.ogg"); - }); - cy.contains(".mx_AudioPlayer_byline", "00:01").should("exist"); - cy.contains(".mx_AudioPlayer_byline", "(3.56 KB)").should("exist"); // actual size - }); - - // Assert that the counter is zero before clicking the play button - cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); - - // Click the play button - cy.findByRole("button", { name: "Play" }).click(); - - // Assert that the pause button is rendered - 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 the play button is rendered - cy.findByRole("button", { name: "Play" }).should("exist"); - }); - }); - }); - }); - }); - - it("should render file size in kibibytes on a file tile", () => { - const size = "1.12 KB"; // actual file size in kibibytes (1024 bytes) - - // Upload a file - uploadFile("cypress/fixtures/matrix-org-client-versions.json"); - - cy.get(".mx_FilePanel .mx_EventTile").within(() => { - // Assert that the file size is displayed in kibibytes, not kilobytes (1000 bytes) - // See: https://github.com/vector-im/element-web/issues/24866 - cy.contains(".mx_MFileBody_info_filename", size).should("exist"); - cy.get(".mx_MFileBody_download").within(() => { - cy.contains("a", size).should("exist"); - cy.contains(".mx_MImageBody_size", size).should("exist"); - }); - }); - }); - - it("should not add inline padding to a tile when it is selected with right click", () => { - // Upload a file - uploadFile("cypress/fixtures/1sec.ogg"); - - cy.get(".mx_FilePanel .mx_RoomView_MessageList").within(() => { - // Wait until the spinner of the audio player vanishes - cy.get(".mx_InlineSpinner").should("not.exist"); - - // Right click the uploaded file to select the tile - cy.get(".mx_EventTile").rightclick(); - - // Assert that inline padding is not applied - cy.get(".mx_EventTile_selected .mx_EventTile_line").should("have.css", "padding-inline", "0px"); - }); - }); - }); - - describe("download", () => { - it("should download an image via the link on the panel", () => { - // Upload an image file - uploadFile("cypress/fixtures/riot.png"); - - cy.get(".mx_FilePanel").within(() => { - cy.get(".mx_RoomView_MessageList").within(() => { - // Detect the image file on the panel - cy.get(".mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody").within(() => { - // Click the anchor link (not the image itself) - cy.get(".mx_MFileBody_download a").click(); - cy.readFile("cypress/downloads/riot.png").should("exist"); - }); - }); - }); - }); - }); -}); diff --git a/cypress/e2e/right-panel/notification-panel.spec.ts b/cypress/e2e/right-panel/notification-panel.spec.ts deleted file mode 100644 index 4068285070b..00000000000 --- a/cypress/e2e/right-panel/notification-panel.spec.ts +++ /dev/null @@ -1,52 +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"; - -const ROOM_NAME = "Test room"; -const NAME = "Alice"; - -describe("NotificationPanel", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, NAME).then(() => { - cy.createRoom({ name: ROOM_NAME }); - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should render empty state", () => { - cy.viewRoomByName(ROOM_NAME); - cy.findByRole("button", { name: "Notifications" }).click(); - - // Wait until the information about the empty state is rendered - cy.get(".mx_NotificationPanel_empty").should("exist"); - - // Take a snapshot of RightPanel - cy.get(".mx_RightPanel").percySnapshotElement("Notification Panel - empty", { - widths: [264], // Emulate the UI. The value is based on minWidth specified on MainSplit.tsx - }); - }); -}); diff --git a/cypress/e2e/right-panel/right-panel.spec.ts b/cypress/e2e/right-panel/right-panel.spec.ts deleted file mode 100644 index ec840844639..00000000000 --- a/cypress/e2e/right-panel/right-panel.spec.ts +++ /dev/null @@ -1,185 +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 Chainable = Cypress.Chainable; - -const ROOM_NAME = "Test room"; -const ROOM_NAME_LONG = - "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 ut " + - "aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum " + - "dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui " + - "officia deserunt mollit anim id est laborum."; -const SPACE_NAME = "Test space"; -const NAME = "Alice"; -const ROOM_ADDRESS_LONG = - "loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua"; - -const getMemberTileByName = (name: string): Chainable> => { - return cy.get(`.mx_EntityTile, [title="${name}"]`); -}; - -const viewRoomSummaryByName = (name: string): Chainable> => { - cy.viewRoomByName(name); - cy.findByRole("button", { name: "Room info" }).click(); - return checkRoomSummaryCard(name); -}; - -const checkRoomSummaryCard = (name: string): Chainable> => { - cy.get(".mx_RoomSummaryCard").should("have.length", 1); - return cy.get(".mx_BaseCard_header").should("contain", name); -}; - -describe("RightPanel", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, NAME).then(() => - cy.window({ log: false }).then(() => { - cy.createRoom({ name: ROOM_NAME }); - cy.createSpace({ name: SPACE_NAME }); - }), - ); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - describe("in rooms", () => { - it("should handle long room address and long room name", () => { - cy.createRoom({ name: ROOM_NAME_LONG }); - viewRoomSummaryByName(ROOM_NAME_LONG); - - cy.openRoomSettings(); - - // Set a local room address - cy.contains(".mx_SettingsFieldset", "Local Addresses").within(() => { - cy.findByRole("textbox").type(ROOM_ADDRESS_LONG); - cy.findByRole("button", { name: "Add" }).click(); - cy.findByText(`#${ROOM_ADDRESS_LONG}:localhost`) - .should("have.class", "mx_EditableItem_item") - .should("exist"); - }); - - cy.closeDialog(); - - // Close and reopen the right panel to render the room address - cy.findByRole("button", { name: "Room info" }).click(); - cy.get(".mx_RightPanel").should("not.exist"); - cy.findByRole("button", { name: "Room info" }).click(); - - cy.get(".mx_RightPanel").percySnapshotElement("RoomSummaryCard - with a room name and a local address", { - widths: [264], // Emulate the UI. The value is based on minWidth specified on MainSplit.tsx - }); - }); - - it("should handle clicking add widgets", () => { - viewRoomSummaryByName(ROOM_NAME); - - cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); - cy.get(".mx_IntegrationManager").should("have.length", 1); - }); - - it("should handle viewing export chat", () => { - viewRoomSummaryByName(ROOM_NAME); - - cy.findByRole("button", { name: "Export chat" }).click(); - cy.get(".mx_ExportDialog").should("have.length", 1); - }); - - it("should handle viewing share room", () => { - viewRoomSummaryByName(ROOM_NAME); - - cy.findByRole("button", { name: "Share room" }).click(); - cy.get(".mx_ShareDialog").should("have.length", 1); - }); - - it("should handle viewing room settings", () => { - viewRoomSummaryByName(ROOM_NAME); - - cy.findByRole("button", { name: "Room settings" }).click(); - cy.get(".mx_RoomSettingsDialog").should("have.length", 1); - cy.get(".mx_Dialog_title").within(() => { - cy.findByText("Room Settings - " + ROOM_NAME).should("exist"); - }); - }); - - it("should handle viewing files", () => { - viewRoomSummaryByName(ROOM_NAME); - - cy.findByRole("button", { name: "Files" }).click(); - cy.get(".mx_FilePanel").should("have.length", 1); - cy.get(".mx_FilePanel_empty").should("have.length", 1); - - cy.findByRole("button", { name: "Room information" }).click(); - checkRoomSummaryCard(ROOM_NAME); - }); - - it("should handle viewing room member", () => { - viewRoomSummaryByName(ROOM_NAME); - - // \d represents the number of the room members inside mx_BaseCard_Button_sublabel - cy.findByRole("button", { name: /People \d/ }).click(); - cy.get(".mx_MemberList").should("have.length", 1); - - getMemberTileByName(NAME).click(); - cy.get(".mx_UserInfo").should("have.length", 1); - cy.get(".mx_UserInfo_profile").within(() => { - cy.findByText(NAME); - }); - - cy.findByRole("button", { name: "Room members" }).click(); - cy.get(".mx_MemberList").should("have.length", 1); - - cy.findByRole("button", { name: "Room information" }).click(); - checkRoomSummaryCard(ROOM_NAME); - }); - }); - - describe("in spaces", () => { - it("should handle viewing space member", () => { - cy.viewSpaceHomeByName(SPACE_NAME); - - cy.get(".mx_RoomInfoLine_private").within(() => { - // \d represents the number of the space members - cy.findByRole("button", { name: /\d member/ }).click(); - }); - cy.get(".mx_MemberList").should("have.length", 1); - cy.get(".mx_RightPanel_scopeHeader").within(() => { - cy.findByText(SPACE_NAME); - }); - - getMemberTileByName(NAME).click(); - cy.get(".mx_UserInfo").should("have.length", 1); - cy.get(".mx_UserInfo_profile").within(() => { - cy.findByText(NAME); - }); - cy.get(".mx_RightPanel_scopeHeader").within(() => { - cy.findByText(SPACE_NAME); - }); - - cy.findByRole("button", { name: "Back" }).click(); - cy.get(".mx_MemberList").should("have.length", 1); - }); - }); -}); diff --git a/cypress/e2e/room-directory/room-directory.spec.ts b/cypress/e2e/room-directory/room-directory.spec.ts deleted file mode 100644 index a7fcfaf61f1..00000000000 --- a/cypress/e2e/room-directory/room-directory.spec.ts +++ /dev/null @@ -1,106 +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"; - -describe("Room Directory", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Ray"); - cy.getBot(homeserver, { displayName: "Paul" }).as("bot"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should allow admin to add alias & publish room to directory", () => { - cy.window({ log: false }).then((win) => { - cy.createRoom({ - name: "Gaming", - preset: win.matrixcs.Preset.PublicChat, - }).as("roomId"); - }); - - cy.viewRoomByName("Gaming"); - cy.openRoomSettings(); - - // First add a local address `gaming` - cy.contains(".mx_SettingsFieldset", "Local Addresses").within(() => { - cy.findByRole("textbox").type("gaming"); - cy.findByRole("button", { name: "Add" }).click(); - cy.findByText("#gaming:localhost").should("have.class", "mx_EditableItem_item").should("exist"); - }); - - // Publish into the public rooms directory - cy.contains(".mx_SettingsFieldset", "Published Addresses").within(() => { - cy.get("#canonicalAlias").find(":selected").findByText("#gaming:localhost"); - cy.findByLabelText("Publish this room to the public in localhost's room directory?") - .click() - .should("have.attr", "aria-checked", "true"); - }); - - cy.closeDialog(); - - cy.all([cy.get("@bot"), cy.get("@roomId")]).then(async ([bot, roomId]) => { - const resp = await bot.publicRooms({}); - expect(resp.total_room_count_estimate).to.equal(1); - expect(resp.chunk).to.have.length(1); - expect(resp.chunk[0].room_id).to.equal(roomId); - }); - }); - - it("should allow finding published rooms in directory", () => { - const name = "This is a public room"; - cy.all([cy.window({ log: false }), cy.get("@bot")]).then(([win, bot]) => { - bot.createRoom({ - visibility: win.matrixcs.Visibility.Public, - name, - room_alias_name: "test1234", - }); - }); - - cy.findByRole("button", { name: "Explore rooms" }).click(); - - cy.get(".mx_SpotlightDialog").within(() => { - cy.findByRole("textbox", { name: "Search" }).type("Unknown Room"); - cy.findByText("If you can't find the room you're looking for, ask for an invite or create a new room.") - .should("have.class", "mx_SpotlightDialog_otherSearches_messageSearchText") - .should("exist"); - }); - cy.get(".mx_SpotlightDialog_wrapper").percySnapshotElement("Room Directory - filtered no results"); - - cy.get(".mx_SpotlightDialog").within(() => { - cy.findByRole("textbox", { name: "Search" }).type("{selectAll}{backspace}test1234"); - cy.findByText(name).should("have.class", "mx_SpotlightDialog_result_publicRoomName").should("exist"); - }); - - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 - //cy.get(".mx_SpotlightDialog_wrapper").percySnapshotElement("Room Directory - filtered one result"); - - cy.get(".mx_SpotlightDialog .mx_SpotlightDialog_option").findByRole("button", { name: "Join" }).click(); - - cy.url().should("contain", `/#/room/#test1234:localhost`); - }); -}); diff --git a/cypress/e2e/room/room-header.spec.ts b/cypress/e2e/room/room-header.spec.ts deleted file mode 100644 index fc20dfbebee..00000000000 --- a/cypress/e2e/room/room-header.spec.ts +++ /dev/null @@ -1,292 +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 { IWidget } from "matrix-widget-api"; - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; - -describe("Room Header", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Sakura"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should render default buttons properly", () => { - cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room"); - - cy.get(".mx_RoomHeader").within(() => { - // Names (aria-label) of every button rendered on mx_RoomHeader by default - const expectedButtonNames = [ - "Room options", // The room name button next to the room avatar, which renders dropdown menu on click - "Voice call", - "Video call", - "Search", - "Threads", - "Notifications", - "Room info", - ]; - - // Assert they are found and visible - for (const name of expectedButtonNames) { - cy.findByRole("button", { name }).should("be.visible"); - } - - // Assert that just those seven buttons exist on mx_RoomHeader by default - cy.findAllByRole("button").should("have.length", 7); - }); - - cy.get(".mx_RoomHeader").percySnapshotElement("Room header"); - }); - - it("should render the pin button for pinned messages card", () => { - cy.enableLabsFeature("feature_pinning"); - - cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room"); - - cy.getComposer().type("Test message{enter}"); - - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Options" }).click(); - - cy.findByRole("menuitem", { name: "Pin" }).should("be.visible").click(); - - cy.get(".mx_RoomHeader").within(() => { - cy.findByRole("button", { name: "Pinned messages" }).should("be.visible"); - }); - }); - - it("should render a very long room name without collapsing the buttons", () => { - const LONG_ROOM_NAME = - "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 ut " + - "aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum " + - "dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui " + - "officia deserunt mollit anim id est laborum."; - - cy.createRoom({ name: LONG_ROOM_NAME }).viewRoomByName(LONG_ROOM_NAME); - - cy.get(".mx_RoomHeader").within(() => { - // Wait until the room name is set - cy.get(".mx_RoomHeader_nametext").within(() => { - cy.findByText(LONG_ROOM_NAME).should("exist"); - }); - - // Assert the size of buttons on RoomHeader are specified and the buttons are not compressed - // Note these assertions do not check the size of mx_RoomHeader_name button - cy.get(".mx_RoomHeader_button") - .should("have.length", 6) - .should("be.visible") - .should("have.css", "height", "32px") - .should("have.css", "width", "32px"); - }); - - cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with a long room name", { - widths: [300, 600], // Magic numbers to emulate the narrow RoomHeader on the actual UI - }); - }); - - it("should have buttons highlighted by being clicked", () => { - cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room"); - - cy.get(".mx_RoomHeader").within(() => { - // Check these buttons - const buttonsHighlighted = ["Threads", "Notifications", "Room info"]; - - for (const name of buttonsHighlighted) { - cy.findByRole("button", { name: name }) - .click() // Highlight the button - .then(($btn) => { - // Note it is not possible to get CSS values of a pseudo class with "have.css". - const color = $btn[0].ownerDocument.defaultView // get window reference from element - .getComputedStyle($btn[0], "before") // get the pseudo selector - .getPropertyValue("background-color"); // get "background-color" value - - // Assert the value is equal to $accent == hex #0dbd8b == rgba(13, 189, 139) - expect(color).to.eq("rgb(13, 189, 139)"); - }); - } - }); - - cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with a highlighted button"); - }); - - describe("with a video room", () => { - const createVideoRoom = () => { - // Enable video rooms. This command reloads the app - cy.setSettingValue("feature_video_rooms", null, SettingLevel.DEVICE, true); - - cy.get(".mx_LeftPanel_roomListContainer", { timeout: 20000 }) - .findByRole("button", { name: "Add room" }) - .click(); - - cy.findByRole("menuitem", { name: "New video room" }).click(); - - cy.findByRole("textbox", { name: "Name" }).type("Test video room"); - - cy.findByRole("button", { name: "Create video room" }).click(); - - cy.viewRoomByName("Test video room"); - }; - - it("should render buttons for room options, beta pill, invite, chat, and room info", () => { - createVideoRoom(); - - cy.get(".mx_RoomHeader").within(() => { - // 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) { - cy.findByRole("button", { name }).should("be.visible"); - } - - // Assert that there is not a button except those buttons - cy.findAllByRole("button").should("have.length", 5); - }); - - cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with a video room"); - }); - - it("should render a working chat button which opens the timeline on a right panel", () => { - createVideoRoom(); - - cy.get(".mx_RoomHeader").findByRole("button", { name: "Chat" }).click(); - - // Assert that the video is rendered - cy.get(".mx_CallView video").should("exist"); - - cy.get(".mx_RightPanel .mx_TimelineCard") - .should("exist") - .within(() => { - // Assert that GELS is visible - cy.findByText("Sakura created and configured the room.").should("exist"); - }); - }); - }); - - describe("with a widget", () => { - const ROOM_NAME = "Test Room with a widget"; - const WIDGET_ID = "fake-widget"; - const WIDGET_HTML = ` - - - Fake Widget - - - Hello World - - - `; - - let widgetUrl: string; - let roomId: string; - - beforeEach(() => { - 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); - }); - }); - - it("should highlight the apps button", () => { - // Assert that AppsDrawer is rendered - cy.get(".mx_AppsDrawer").should("exist"); - - cy.get(".mx_RoomHeader").within(() => { - // Assert that "Hide Widgets" button is rendered and aria-checked is set to true - cy.findByRole("button", { name: "Hide Widgets" }) - .should("exist") - .should("have.attr", "aria-checked", "true"); - }); - - cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with apps button (highlighted)"); - }); - - it("should support hiding a widget", () => { - cy.get(".mx_AppsDrawer").should("exist"); - - cy.get(".mx_RoomHeader").within(() => { - // Click the apps button to hide AppsDrawer - cy.findByRole("button", { name: "Hide Widgets" }).should("exist").click(); - - // Assert that "Show widgets" button is rendered and aria-checked is set to false - cy.findByRole("button", { name: "Show Widgets" }) - .should("exist") - .should("have.attr", "aria-checked", "false"); - }); - - // Assert that AppsDrawer is not rendered - cy.get(".mx_AppsDrawer").should("not.exist"); - - cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with apps button (not highlighted)"); - }); - }); -}); diff --git a/cypress/e2e/room/room.spec.ts b/cypress/e2e/room/room.spec.ts deleted file mode 100644 index 843258a7780..00000000000 --- a/cypress/e2e/room/room.spec.ts +++ /dev/null @@ -1,81 +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/@types/event"; - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { MatrixClient } from "../../global"; - -describe("Room Directory", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Alice"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should switch between existing dm rooms without a loader", () => { - let bobClient: MatrixClient; - let charlieClient: MatrixClient; - cy.getBot(homeserver, { - displayName: "Bob", - }).then((bob) => { - bobClient = bob; - }); - - cy.getBot(homeserver, { - displayName: "Charlie", - }).then((charlie) => { - charlieClient = charlie; - }); - - // create dms with bob and charlie - cy.getClient().then(async (cli) => { - const bobRoom = await cli.createRoom({ is_direct: true }); - const charlieRoom = await cli.createRoom({ is_direct: true }); - await cli.invite(bobRoom.room_id, bobClient.getUserId()); - await cli.invite(charlieRoom.room_id, charlieClient.getUserId()); - await cli.setAccountData("m.direct" as EventType, { - [bobClient.getUserId()]: [bobRoom.room_id], - [charlieClient.getUserId()]: [charlieRoom.room_id], - }); - }); - - cy.wait(250); // let the room list settle - - cy.viewRoomByName("Bob"); - - // short timeout because loader is only visible for short period - // we want to make sure it is never displayed when switching these rooms - cy.get(".mx_RoomPreviewBar_spinnerTitle", { timeout: 1 }).should("not.exist"); - // confirm the room was loaded - cy.findByText("Bob joined the room").should("exist"); - - cy.viewRoomByName("Charlie"); - cy.get(".mx_RoomPreviewBar_spinnerTitle", { timeout: 1 }).should("not.exist"); - // confirm the room was loaded - cy.findByText("Charlie joined the room").should("exist"); - }); -}); diff --git a/cypress/e2e/settings/appearance-user-settings-tab.spec.ts b/cypress/e2e/settings/appearance-user-settings-tab.spec.ts deleted file mode 100644 index cb22d26b58b..00000000000 --- a/cypress/e2e/settings/appearance-user-settings-tab.spec.ts +++ /dev/null @@ -1,328 +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"; - -describe("Appearance user settings tab", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Hanako"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should be rendered properly", () => { - cy.openUserSettings("Appearance"); - - cy.findByTestId("mx_AppearanceUserSettingsTab").within(() => { - cy.get("h2").should("have.text", "Customise your appearance").should("be.visible"); - }); - - cy.findByTestId("mx_AppearanceUserSettingsTab").percySnapshotElement( - "User settings tab - Appearance (advanced options collapsed)", - { - // Emulate TabbedView's actual min and max widths - // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width - // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right) - widths: [580, 796], - }, - ); - - // Click "Show advanced" link button - cy.findByRole("button", { name: "Show advanced" }).click(); - - // Assert that "Hide advanced" link button is rendered - cy.findByRole("button", { name: "Hide advanced" }).should("exist"); - - cy.findByTestId("mx_AppearanceUserSettingsTab").percySnapshotElement( - "User settings tab - Appearance (advanced options expanded)", - { - // Emulate TabbedView's actual min and max widths - // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width - // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right) - widths: [580, 796], - }, - ); - }); - - it("should support switching layouts", () => { - // Create and view a room first - cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room"); - - cy.openUserSettings("Appearance"); - - cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { - // Assert that the layout selected by default is "Modern" - cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => { - cy.findByLabelText("Modern").should("exist"); - }); - }); - - // Assert that the room layout is set to group (modern) layout - cy.get(".mx_RoomView_body[data-layout='group']").should("exist"); - - cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { - // Select the first layout - cy.get(".mx_LayoutSwitcher_RadioButton").first().click(); - - // Assert that the layout selected is "IRC (Experimental)" - cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => { - cy.findByLabelText("IRC (Experimental)").should("exist"); - }); - }); - - // Assert that the room layout is set to IRC layout - cy.get(".mx_RoomView_body[data-layout='irc']").should("exist"); - - cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { - // Select the last layout - cy.get(".mx_LayoutSwitcher_RadioButton").last().click(); - - // Assert that the layout selected is "Message bubbles" - cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => { - cy.findByLabelText("Message bubbles").should("exist"); - }); - }); - - // Assert that the room layout is set to bubble layout - cy.get(".mx_RoomView_body[data-layout='bubble']").should("exist"); - }); - - it("should support changing font size by clicking the font slider", () => { - cy.openUserSettings("Appearance"); - - cy.findByTestId("mx_AppearanceUserSettingsTab").within(() => { - cy.get(".mx_FontScalingPanel_fontSlider").within(() => { - cy.findByLabelText("Font size").should("exist"); - }); - - cy.get(".mx_FontScalingPanel_fontSlider").within(() => { - // Click the left position of the slider - cy.get("input").realClick({ position: "left" }); - - // Assert that the smallest font size is selected - cy.get("input[value='13']").should("exist"); - cy.get("output .mx_Slider_selection_label").findByText("13"); - }); - - cy.get(".mx_FontScalingPanel_fontSlider").percySnapshotElement("Font size slider - smallest (13)", { - widths: [486], // actual size (content-box, including inline padding) - }); - - cy.get(".mx_FontScalingPanel_fontSlider").within(() => { - // Click the right position of the slider - cy.get("input").realClick({ position: "right" }); - - // Assert that the largest font size is selected - cy.get("input[value='18']").should("exist"); - cy.get("output .mx_Slider_selection_label").findByText("18"); - }); - - cy.get(".mx_FontScalingPanel_fontSlider").percySnapshotElement("Font size slider - largest (18)", { - widths: [486], - }); - }); - }); - - it("should disable font size slider when custom font size is used", () => { - cy.openUserSettings("Appearance"); - - cy.findByTestId("mx_FontScalingPanel").within(() => { - cy.findByLabelText("Use custom size").click({ force: true }); // force click as checkbox size is zero - - // Assert that the font slider is disabled - cy.get(".mx_FontScalingPanel_fontSlider input[disabled]").should("exist"); - }); - }); - - it("should support enabling compact group (modern) layout", () => { - // Create and view a room first - cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room"); - - cy.openUserSettings("Appearance"); - - // Click "Show advanced" link button - cy.findByRole("button", { name: "Show advanced" }).click(); - - // force click as checkbox size is zero - cy.findByLabelText("Use a more compact 'Modern' layout").click({ force: true }); - - // Assert that the room layout is set to compact group (modern) layout - cy.get("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout").should("exist"); - }); - - it("should disable compact group (modern) layout option on IRC layout and bubble layout", () => { - const checkDisabled = () => { - cy.findByLabelText("Use a more compact 'Modern' layout").should("be.disabled"); - }; - - cy.openUserSettings("Appearance"); - - // Click "Show advanced" link button - cy.findByRole("button", { name: "Show advanced" }).click(); - - // Enable IRC layout - cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { - // Select the first layout - cy.get(".mx_LayoutSwitcher_RadioButton").first().click(); - - // Assert that the layout selected is "IRC (Experimental)" - cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => { - cy.findByLabelText("IRC (Experimental)").should("exist"); - }); - }); - - checkDisabled(); - - // Enable bubble layout - cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { - // Select the first layout - cy.get(".mx_LayoutSwitcher_RadioButton").last().click(); - - // Assert that the layout selected is "IRC (Experimental)" - cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => { - cy.findByLabelText("Message bubbles").should("exist"); - }); - }); - - checkDisabled(); - }); - - it("should support enabling system font", () => { - cy.openUserSettings("Appearance"); - - // Click "Show advanced" link button - cy.findByRole("button", { name: "Show advanced" }).click(); - - // force click as checkbox size is zero - cy.findByLabelText("Use a system font").click({ force: true }); - - // Assert that the font-family value was removed - cy.get("body").should("have.css", "font-family", '""'); - }); - - describe("Theme Choice Panel", () => { - beforeEach(() => { - // Disable the default theme for consistency in case ThemeWatcher automatically chooses it - cy.setSettingValue("use_system_theme", null, SettingLevel.DEVICE, false); - }); - - it("should be rendered with the light theme selected", () => { - cy.openUserSettings("Appearance") - .findByTestId("mx_ThemeChoicePanel") - .within(() => { - cy.findByTestId("checkbox-use-system-theme").within(() => { - cy.findByText("Match system theme").should("be.visible"); - - // Assert that 'Match system theme' is not checked - // Note that mx_Checkbox_checkmark exists and is hidden by CSS if it is not checked - cy.get(".mx_Checkbox_checkmark").should("not.be.visible"); - }); - - cy.findByTestId("theme-choice-panel-selectors").within(() => { - cy.get(".mx_ThemeSelector_light").should("exist"); - cy.get(".mx_ThemeSelector_dark").should("exist"); - - // Assert that the light theme is selected - cy.get(".mx_ThemeSelector_light.mx_StyledRadioButton_enabled").should("exist"); - - // Assert that the buttons for the light and dark theme are not enabled - cy.get(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled").should("not.exist"); - cy.get(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled").should("not.exist"); - }); - - // Assert that the checkbox for the high contrast theme is rendered - cy.findByLabelText("Use high contrast").should("exist"); - }); - }); - - it( - "should disable the labels for themes and the checkbox for the high contrast theme if the checkbox for " + - "the system theme is clicked", - () => { - cy.openUserSettings("Appearance") - .findByTestId("mx_ThemeChoicePanel") - .findByLabelText("Match system theme") - .click({ force: true }); // force click because the size of the checkbox is zero - - cy.findByTestId("mx_ThemeChoicePanel").within(() => { - // Assert that the labels for the light theme and dark theme are disabled - cy.get(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled").should("exist"); - cy.get(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled").should("exist"); - - // Assert that there does not exist a label for an enabled theme - cy.get("label.mx_StyledRadioButton_enabled").should("not.exist"); - - // Assert that the checkbox and label to enable the the high contrast theme should not exist - cy.findByLabelText("Use high contrast").should("not.exist"); - }); - }, - ); - - it( - "should not render the checkbox and the label for the high contrast theme " + - "if the dark theme is selected", - () => { - cy.openUserSettings("Appearance"); - - // Assert that the checkbox and the label to enable the high contrast theme should exist - cy.findByLabelText("Use high contrast").should("exist"); - - // Enable the dark theme - cy.get(".mx_ThemeSelector_dark").click(); - - // Assert that the checkbox and the label should not exist - cy.findByLabelText("Use high contrast").should("not.exist"); - }, - ); - - it("should support enabling the high contast theme", () => { - cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room"); - - cy.get(".mx_GenericEventListSummary").within(() => { - // Assert that $primary-content is applied to GELS summary on the light theme - // $primary-content on the light theme = #17191c = rgb(23, 25, 28) - cy.get(".mx_TextualEvent.mx_GenericEventListSummary_summary") - .should("have.css", "color", "rgb(23, 25, 28)") - .should("have.css", "opacity", "0.5"); - }); - - 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(); - - cy.get(".mx_GenericEventListSummary").within(() => { - // Assert that $secondary-content is specified for GELS summary on the high contrast theme - // $secondary-content on the high contrast theme = #5e6266 = rgb(94, 98, 102) - cy.get(".mx_TextualEvent.mx_GenericEventListSummary_summary") - .should("have.css", "color", "rgb(94, 98, 102)") - .should("have.css", "opacity", "1"); - }); - }); - }); -}); diff --git a/cypress/e2e/settings/device-management.spec.ts b/cypress/e2e/settings/device-management.spec.ts deleted file mode 100644 index 06795b68bef..00000000000 --- a/cypress/e2e/settings/device-management.spec.ts +++ /dev/null @@ -1,140 +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 type { UserCredentials } from "../../support/login"; - -describe("Device manager", () => { - let homeserver: HomeserverInstance | undefined; - let user: UserCredentials | undefined; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Alice") - .then((credentials) => { - user = credentials; - }) - .then(() => { - // create some extra sessions to manage - return cy.loginUser(homeserver, user.username, user.password); - }) - .then(() => { - return cy.loginUser(homeserver, user.username, user.password); - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver!); - }); - - it("should display sessions", () => { - cy.openUserSettings("Sessions"); - cy.findByText("Current session").should("exist"); - - cy.findByTestId("current-session-section").within(() => { - cy.findByText("Unverified session").should("exist"); - - // current session details opened - cy.findByRole("button", { name: "Show details" }).click(); - cy.findByText("Session details").should("exist"); - - // close current session details - cy.findByRole("button", { name: "Hide details" }).click(); - cy.findByText("Session details").should("not.exist"); - }); - - cy.findByTestId("security-recommendations-section").within(() => { - cy.findByText("Security recommendations").should("exist"); - cy.findByRole("button", { name: "View all (3)" }).click(); - }); - - /** - * Other sessions section - */ - cy.findByText("Other sessions").should("exist"); - // filter applied after clicking through from security recommendations - cy.findByLabelText("Filter devices").should("have.text", "Show: Unverified"); - cy.get(".mx_FilteredDeviceList_list").within(() => { - cy.get(".mx_FilteredDeviceList_listItem").should("have.length", 3); - - // select two sessions - cy.get(".mx_FilteredDeviceList_listItem") - .first() - .within(() => { - // force click as the input element itself is not visible (its size is zero) - cy.findByRole("checkbox").click({ force: true }); - }); - cy.get(".mx_FilteredDeviceList_listItem") - .last() - .within(() => { - // force click as the input element itself is not visible (its size is zero) - cy.findByRole("checkbox").click({ force: true }); - }); - }); - // sign out from list selection action buttons - cy.findByRole("button", { name: "Sign out" }).click(); - cy.get(".mx_Dialog .mx_QuestionDialog").within(() => { - cy.findByRole("button", { name: "Sign out" }).click(); - }); - // list updated after sign out - cy.get(".mx_FilteredDeviceList_list").find(".mx_FilteredDeviceList_listItem").should("have.length", 1); - // security recommendation count updated - cy.findByRole("button", { name: "View all (1)" }); - - const sessionName = `Alice's device`; - // open the first session - cy.get(".mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem") - .first() - .within(() => { - cy.findByRole("button", { name: "Show details" }).click(); - - cy.findByText("Session details").should("exist"); - - cy.findByRole("button", { name: "Rename" }).click(); - cy.findByTestId("device-rename-input").type(sessionName); - cy.findByRole("button", { name: "Save" }).click(); - // there should be a spinner while device updates - cy.get(".mx_Spinner").should("exist"); - // wait for spinner to complete - cy.get(".mx_Spinner").should("not.exist"); - - // session name updated in details - cy.get(".mx_DeviceDetailHeading h4").within(() => { - cy.findByText(sessionName); - }); - // and main list item - cy.get(".mx_DeviceTile h4").within(() => { - cy.findByText(sessionName); - }); - - // sign out using the device details sign out - cy.findByRole("button", { name: "Sign out of this session" }).click(); - }); - // confirm the signout - cy.get(".mx_Dialog .mx_QuestionDialog").within(() => { - cy.findByRole("button", { name: "Sign out" }).click(); - }); - - // no other sessions or security recommendations sections when only one session - cy.findByText("Other sessions").should("not.exist"); - cy.findByTestId("security-recommendations-section").should("not.exist"); - }); -}); diff --git a/cypress/e2e/settings/general-user-settings-tab.spec.ts b/cypress/e2e/settings/general-user-settings-tab.spec.ts deleted file mode 100644 index 2879d6d9301..00000000000 --- a/cypress/e2e/settings/general-user-settings-tab.spec.ts +++ /dev/null @@ -1,236 +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"; - -const USER_NAME = "Bob"; -const USER_NAME_NEW = "Alice"; -const IntegrationManager = "scalar.vector.im"; - -describe("General user settings tab", () => { - let homeserver: HomeserverInstance; - let userId: string; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, USER_NAME).then((user) => (userId = user.userId)); - cy.tweakConfig({ default_country_code: "US" }); // For checking the international country calling code - }); - cy.openUserSettings("General"); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should be rendered properly", () => { - // Exclude userId from snapshots - const percyCSS = ".mx_ProfileSettings_profile_controls_userId { visibility: hidden !important; }"; - - cy.findByTestId("mx_GeneralUserSettingsTab").percySnapshotElement("User settings tab - General", { - percyCSS, - // Emulate TabbedView's actual min and max widths - // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width - // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right) - widths: [580, 796], - }); - - cy.findByTestId("mx_GeneralUserSettingsTab").within(() => { - // Assert that the top heading is rendered - cy.findByText("General").should("be.visible"); - - cy.get(".mx_ProfileSettings_profile") - .scrollIntoView() - .within(() => { - // Assert USER_NAME is rendered - cy.findByRole("textbox", { name: "Display Name" }) - .get(`input[value='${USER_NAME}']`) - .should("be.visible"); - - // Assert that a userId is rendered - cy.get(".mx_ProfileSettings_profile_controls_userId").within(() => { - cy.findByText(userId).should("exist"); - }); - - // Check avatar setting - cy.get(".mx_AvatarSetting_avatar") - .should("exist") - .realHover() - .get(".mx_AvatarSetting_avatar_hovering") - .within(() => { - // Hover effect - cy.get(".mx_AvatarSetting_hoverBg").should("exist"); - cy.get(".mx_AvatarSetting_hover span").within(() => { - cy.findByText("Upload").should("exist"); - }); - }); - }); - - // Wait until spinners disappear - cy.findByTestId("accountSection").within(() => { - cy.get(".mx_Spinner").should("not.exist"); - }); - cy.findByTestId("discoverySection").within(() => { - cy.get(".mx_Spinner").should("not.exist"); - }); - - cy.findByTestId("accountSection").within(() => { - // Assert that input areas for changing a password exists - cy.get("form.mx_GeneralUserSettingsTab_section--account_changePassword") - .scrollIntoView() - .within(() => { - cy.findByLabelText("Current password").should("be.visible"); - cy.findByLabelText("New Password").should("be.visible"); - cy.findByLabelText("Confirm password").should("be.visible"); - }); - }); - // Check email addresses area - cy.findByTestId("mx_AccountEmailAddresses") - .scrollIntoView() - .within(() => { - // Assert that an input area for a new email address is rendered - cy.findByRole("textbox", { name: "Email Address" }).should("be.visible"); - - // Assert the add button is visible - cy.findByRole("button", { name: "Add" }).should("be.visible"); - }); - - // Check phone numbers area - cy.findByTestId("mx_AccountPhoneNumbers") - .scrollIntoView() - .within(() => { - // Assert that an input area for a new phone number is rendered - cy.findByRole("textbox", { name: "Phone Number" }).should("be.visible"); - - // Assert that the add button is rendered - cy.findByRole("button", { name: "Add" }).should("be.visible"); - }); - - // Check language and region setting dropdown - cy.get(".mx_GeneralUserSettingsTab_section_languageInput") - .scrollIntoView() - .within(() => { - // Check the default value - cy.findByText("English").should("be.visible"); - - // Click the button to display the dropdown menu - cy.findByRole("button", { name: "Language Dropdown" }).click(); - - // Assert that the default option is rendered and highlighted - cy.findByRole("option", { name: /Bahasa Indonesia/ }) - .should("be.visible") - .should("have.class", "mx_Dropdown_option_highlight"); - - // Click again to close the dropdown - cy.findByRole("button", { name: "Language Dropdown" }).click(); - - // Assert that the default value is rendered again - cy.findByText("English").should("be.visible"); - }); - - cy.get("form.mx_SetIdServer") - .scrollIntoView() - .within(() => { - // Assert that an input area for identity server exists - cy.findByRole("textbox", { name: "Enter a new identity server" }).should("be.visible"); - }); - - cy.get(".mx_SetIntegrationManager") - .scrollIntoView() - .within(() => { - cy.contains(".mx_SetIntegrationManager_heading_manager", IntegrationManager).should("be.visible"); - - // Make sure integration manager's toggle switch is enabled - cy.get(".mx_ToggleSwitch_enabled").should("be.visible"); - - cy.get(".mx_SetIntegrationManager_heading_manager").should( - "have.text", - "Manage integrations(scalar.vector.im)", - ); - }); - - // Assert the account deactivation button is displayed - cy.findByTestId("account-management-section") - .scrollIntoView() - .findByRole("button", { name: "Deactivate Account" }) - .should("be.visible") - .should("have.class", "mx_AccessibleButton_kind_danger"); - }); - }); - - it("should support adding and removing a profile picture", () => { - cy.get(".mx_SettingsTab .mx_ProfileSettings").within(() => { - // Upload a picture - cy.get(".mx_ProfileSettings_avatarUpload").selectFile("cypress/fixtures/riot.png", { force: true }); - - // Find and click "Remove" link button - cy.get(".mx_ProfileSettings_profile").within(() => { - cy.findByRole("button", { name: "Remove" }).click(); - }); - - // Assert that the link button disappeared - cy.get(".mx_AvatarSetting_avatar .mx_AccessibleButton_kind_link_sm").should("not.exist"); - }); - }); - - it("should set a country calling code based on default_country_code", () => { - // Check phone numbers area - cy.findByTestId("mx_AccountPhoneNumbers") - .scrollIntoView() - .within(() => { - // Assert that an input area for a new phone number is rendered - cy.findByRole("textbox", { name: "Phone Number" }).should("be.visible"); - - // Check a new phone number dropdown menu - cy.get(".mx_PhoneNumbers_country") - .scrollIntoView() - .within(() => { - // Assert that the country calling code of United States is visible - cy.findByText(/\+1/).should("be.visible"); - - // Click the button to display the dropdown menu - cy.findByRole("button", { name: "Country Dropdown" }).click(); - - // Assert that the option for calling code of United Kingdom is visible - cy.findByRole("option", { name: /United Kingdom/ }).should("be.visible"); - - // Click again to close the dropdown - cy.findByRole("button", { name: "Country Dropdown" }).click(); - - // Assert that the default value is rendered again - cy.findByText(/\+1/).should("be.visible"); - }); - - cy.findByRole("button", { name: "Add" }).should("be.visible"); - }); - }); - - it("should support changing a display name", () => { - cy.get(".mx_SettingsTab .mx_ProfileSettings").within(() => { - // Change the diaplay name to USER_NAME_NEW - cy.findByRole("textbox", { name: "Display Name" }).type(`{selectAll}{del}${USER_NAME_NEW}{enter}`); - }); - - cy.closeDialog(); - - // Assert the avatar's initial characters are set - cy.get(".mx_UserMenu .mx_BaseAvatar_initial").findByText("A").should("exist"); // Alice - cy.get(".mx_RoomView_wrapper .mx_BaseAvatar_initial").findByText("A").should("exist"); // Alice - }); -}); diff --git a/cypress/e2e/settings/hidden-rr-migration.spec.ts b/cypress/e2e/settings/hidden-rr-migration.spec.ts deleted file mode 100644 index 729bf7ebd7b..00000000000 --- a/cypress/e2e/settings/hidden-rr-migration.spec.ts +++ /dev/null @@ -1,90 +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"; - -function seedLabs(homeserver: HomeserverInstance, labsVal: boolean | null): void { - cy.initTestUser(homeserver, "Sally", () => { - // seed labs flag - cy.window({ log: false }).then((win) => { - if (typeof labsVal === "boolean") { - // stringify boolean - win.localStorage.setItem("mx_labs_feature_feature_hidden_read_receipts", `${labsVal}`); - } - }); - }); -} - -function testForVal(settingVal: boolean | null): void { - const testRoomName = "READ RECEIPTS"; - cy.createRoom({ name: testRoomName }).as("roomId"); - cy.all([cy.get("@roomId")]).then(() => { - cy.viewRoomByName(testRoomName).then(() => { - // if we can see the room, then sync is working for us. It's time to see if the - // migration even ran. - - cy.getSettingValue("sendReadReceipts", null, true).should("satisfy", (val) => { - if (typeof settingVal === "boolean") { - return val === settingVal; - } else { - return !val; // falsy - we don't actually care if it's undefined, null, or a literal false - } - }); - }); - }); -} - -describe("Hidden Read Receipts Setting Migration", () => { - // We run this as a full-blown end-to-end test to ensure it works in an integration - // sense. If we unit tested it, we'd be testing that the code works but not that the - // migration actually runs. - // - // Here, we get to test that not only the code works but also that it gets run. Most - // of our interactions are with the JS console as we're honestly just checking that - // things got set correctly. - // - // For a security-sensitive feature like hidden read receipts, it's absolutely vital - // that we migrate the setting appropriately. - - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should not migrate the lack of a labs flag", () => { - seedLabs(homeserver, null); - testForVal(null); - }); - - it("should migrate labsHiddenRR=false as sendRR=true", () => { - seedLabs(homeserver, false); - testForVal(true); - }); - - it("should migrate labsHiddenRR=true as sendRR=false", () => { - seedLabs(homeserver, true); - testForVal(false); - }); -}); diff --git a/cypress/e2e/settings/preferences-user-settings-tab.spec.ts b/cypress/e2e/settings/preferences-user-settings-tab.spec.ts deleted file mode 100644 index 61f073e62c7..00000000000 --- a/cypress/e2e/settings/preferences-user-settings-tab.spec.ts +++ /dev/null @@ -1,50 +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"; - -describe("Preferences user settings tab", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Bob"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should be rendered properly", () => { - cy.openUserSettings("Preferences"); - - cy.findByTestId("mx_PreferencesUserSettingsTab").within(() => { - // Assert that the top heading is rendered - cy.contains("Preferences").should("be.visible"); - }); - - cy.findByTestId("mx_PreferencesUserSettingsTab").percySnapshotElement("User settings tab - Preferences", { - // Emulate TabbedView's actual min and max widths - // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width - // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right) - widths: [580, 796], - }); - }); -}); diff --git a/cypress/e2e/settings/security-user-settings-tab.spec.ts b/cypress/e2e/settings/security-user-settings-tab.spec.ts deleted file mode 100644 index 341624dee30..00000000000 --- a/cypress/e2e/settings/security-user-settings-tab.spec.ts +++ /dev/null @@ -1,72 +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"; - -describe("Security user settings tab", () => { - let homeserver: HomeserverInstance; - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - describe("with posthog enabled", () => { - beforeEach(() => { - // Enable posthog - cy.intercept("/config.json?cachebuster=*", (req) => { - req.continue((res) => { - res.send(200, { - ...res.body, - posthog: { - project_api_key: "foo", - api_host: "bar", - }, - privacy_policy_url: "example.tld", // Set privacy policy URL to enable privacyPolicyLink - }); - }); - }); - - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Hanako"); - }); - - // Hide "Notification" toast on Cypress Cloud - cy.contains(".mx_Toast_toast h2", "Notifications") - .should("exist") - .closest(".mx_Toast_toast") - .within(() => { - cy.findByRole("button", { name: "Dismiss" }).click(); - }); - - cy.get(".mx_Toast_buttons").within(() => { - cy.findByRole("button", { name: "Yes" }).should("exist").click(); // Allow analytics - }); - - cy.openUserSettings("Security"); - }); - - describe("AnalyticsLearnMoreDialog", () => { - it("should be rendered properly", () => { - cy.findByRole("button", { name: "Learn more" }).click(); - - cy.get(".mx_AnalyticsLearnMoreDialog_wrapper").percySnapshotElement("AnalyticsLearnMoreDialog"); - }); - }); - }); -}); 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 b7eccb77c62..00000000000 --- 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("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("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("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("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("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("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("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("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("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/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts deleted file mode 100644 index 47228e2bcd1..00000000000 --- a/cypress/e2e/spaces/spaces.spec.ts +++ /dev/null @@ -1,347 +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 { MatrixClient } from "matrix-js-sdk/src/client"; -import type { Preset } from "matrix-js-sdk/src/@types/partials"; -import type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import Chainable = Cypress.Chainable; -import { UserCredentials } from "../../support/login"; - -function openSpaceCreateMenu(): Chainable { - cy.findByRole("button", { name: "Create a space" }).click(); - return cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu"); -} - -function openSpaceContextMenu(spaceName: string): Chainable { - cy.getSpacePanelButton(spaceName).rightclick(); - return cy.get(".mx_SpacePanel_contextMenu"); -} - -function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateRoomOpts { - return { - creation_content: { - type: "m.space", - }, - initial_state: [ - { - type: "m.room.name", - content: { - name: spaceName, - }, - }, - ...roomIds.map(spaceChildInitialState), - ], - }; -} - -function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state"]["0"] { - return { - type: "m.space.child", - state_key: roomId, - content: { - via: [roomId.split(":")[1]], - }, - }; -} - -describe("Spaces", () => { - let homeserver: HomeserverInstance; - let user: UserCredentials; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Sue").then((_user) => { - user = _user; - cy.mockClipboard(); - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should allow user to create public space", () => { - openSpaceCreateMenu(); - cy.get("#mx_ContextualMenu_Container").percySnapshotElement("Space create menu"); - cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu").within(() => { - // Regex pattern due to strings of "mx_SpaceCreateMenuType_public" - cy.findByRole("button", { name: /Public/ }).click(); - - cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( - "cypress/fixtures/riot.png", - { force: true }, - ); - cy.findByRole("textbox", { name: "Name" }).type("Let's have a Riot"); - cy.findByRole("textbox", { name: "Address" }).should("have.value", "lets-have-a-riot"); - cy.findByRole("textbox", { name: "Description" }).type("This is a space to reminisce Riot.im!"); - cy.findByRole("button", { name: "Create" }).click(); - }); - - // Create the default General & Random rooms, as well as a custom "Jokes" room - cy.findByPlaceholderText("General").should("exist"); - cy.findByPlaceholderText("Random").should("exist"); - cy.findByPlaceholderText("Support").type("Jokes"); - cy.findByRole("button", { name: "Continue" }).click(); - - // Copy matrix.to link - // Regex pattern due to strings of "mx_SpacePublicShare_shareButton" - cy.findByRole("button", { name: /Share invite link/ }).realClick(); - cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost"); - - // Go to space home - cy.findByRole("button", { name: "Go to my first room" }).click(); - - // Assert rooms exist in the room list - cy.findByRole("treeitem", { name: "General" }).should("exist"); - cy.findByRole("treeitem", { name: "Random" }).should("exist"); - cy.findByRole("treeitem", { name: "Jokes" }).should("exist"); - }); - - it("should allow user to create private space", () => { - openSpaceCreateMenu().within(() => { - // Regex pattern due to strings of "mx_SpaceCreateMenuType_private" - cy.findByRole("button", { name: /Private/ }).click(); - - cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( - "cypress/fixtures/riot.png", - { force: true }, - ); - cy.findByRole("textbox", { name: "Name" }).type("This is not a Riot"); - cy.findByRole("textbox", { name: "Address" }).should("not.exist"); - cy.findByRole("textbox", { name: "Description" }).type("This is a private space of mourning Riot.im..."); - cy.findByRole("button", { name: "Create" }).click(); - }); - - // Regex pattern due to strings of "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton" - cy.findByRole("button", { name: /Me and my teammates/ }).click(); - - // Create the default General & Random rooms, as well as a custom "Projects" room - cy.findByPlaceholderText("General").should("exist"); - cy.findByPlaceholderText("Random").should("exist"); - cy.findByPlaceholderText("Support").type("Projects"); - cy.findByRole("button", { name: "Continue" }).click(); - - cy.get(".mx_SpaceRoomView").percySnapshotElement("Space - 'Invite your teammates' dialog"); - - cy.get(".mx_SpaceRoomView").within(() => { - cy.get("h1").findByText("Invite your teammates"); - cy.findByRole("button", { name: "Skip for now" }).click(); - }); - - // Assert rooms exist in the room list - cy.findByRole("treeitem", { name: "General" }).should("exist"); - cy.findByRole("treeitem", { name: "Random" }).should("exist"); - cy.findByRole("treeitem", { name: "Projects" }).should("exist"); - - // Assert rooms exist in the space explorer - cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "General").should("exist"); - cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Random").should("exist"); - cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Projects").should("exist"); - }); - - it("should allow user to create just-me space", () => { - cy.createRoom({ - name: "Sample Room", - }); - - openSpaceCreateMenu().within(() => { - // Regex pattern due to strings of "mx_SpaceCreateMenuType_private" - cy.findByRole("button", { name: /Private/ }).click(); - - cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( - "cypress/fixtures/riot.png", - { force: true }, - ); - cy.findByRole("textbox", { name: "Address" }).should("not.exist"); - cy.findByRole("textbox", { name: "Description" }).type("This is a personal space to mourn Riot.im..."); - cy.findByRole("textbox", { name: "Name" }).type("This is my Riot{enter}"); - }); - - // Regex pattern due to of strings of "mx_SpaceRoomView_privateScope_justMeButton" - cy.findByRole("button", { name: /Just me/ }).click(); - - cy.findByText("Sample Room").click({ force: true }); // force click as checkbox size is zero - - // Temporal implementation as multiple elements with the role "button" and name "Add" are found - cy.get(".mx_AddExistingToSpace_footer").within(() => { - cy.findByRole("button", { name: "Add" }).click(); - }); - - cy.get(".mx_SpaceHierarchy_list").within(() => { - // Regex pattern due to the strings of "mx_SpaceHierarchy_roomTile_joined" - cy.findByRole("treeitem", { name: /Sample Room/ }).should("exist"); - }); - }); - - it("should allow user to invite another to a space", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { - bot = _bot; - }); - - cy.createSpace({ - visibility: "public" as any, - room_alias_name: "space", - }).as("spaceId"); - - openSpaceContextMenu("#space:localhost").within(() => { - cy.findByRole("menuitem", { name: "Invite" }).click(); - }); - - cy.get(".mx_SpacePublicShare").within(() => { - // Copy link first - // Regex pattern due to strings of "mx_SpacePublicShare_shareButton" - cy.findByRole("button", { name: /Share invite link/ }) - .focus() - .realClick(); - cy.getClipboardText().should("eq", "https://matrix.to/#/#space:localhost"); - // Start Matrix invite flow - // Regex pattern due to strings of "mx_SpacePublicShare_inviteButton" - cy.findByRole("button", { name: /Invite people/ }).click(); - }); - - cy.get(".mx_InviteDialog_other").within(() => { - cy.findByRole("textbox").type(bot.getUserId()); - cy.findByRole("button", { name: "Invite" }).click(); - }); - - cy.get(".mx_InviteDialog_other").should("not.exist"); - }); - - it("should show space invites at the top of the space panel", () => { - cy.createSpace({ - name: "My Space", - }); - cy.getSpacePanelButton("My Space").should("exist"); - - cy.getBot(homeserver, { displayName: "BotBob" }).then({ timeout: 10000 }, async (bot) => { - const { room_id: roomId } = await bot.createRoom(spaceCreateOptions("Space Space")); - await bot.invite(roomId, user.userId); - }); - // Assert that `Space Space` is above `My Space` due to it being an invite - cy.getSpacePanelButton("Space Space") - .should("exist") - .parent() - .next() - .findByRole("button", { name: "My Space" }) - .should("exist"); - }); - - it("should include rooms in space home", () => { - cy.createRoom({ - name: "Music", - }).as("roomId1"); - cy.createRoom({ - name: "Gaming", - }).as("roomId2"); - - const spaceName = "Spacey Mc. Space Space"; - cy.all([cy.get("@roomId1"), cy.get("@roomId2")]).then(([roomId1, roomId2]) => { - cy.createSpace({ - name: spaceName, - initial_state: [spaceChildInitialState(roomId1), spaceChildInitialState(roomId2)], - }).as("spaceId"); - }); - - cy.get("@spaceId").then(() => { - cy.viewSpaceHomeByName(spaceName); - }); - cy.get(".mx_SpaceRoomView .mx_SpaceHierarchy_list").within(() => { - // Regex pattern due to strings in "mx_SpaceHierarchy_roomTile_name" - cy.findByRole("treeitem", { name: /Music/ }).findByRole("button").should("exist"); - cy.findByRole("treeitem", { name: /Gaming/ }) - .findByRole("button") - .should("exist"); - }); - }); - - it("should render subspaces in the space panel only when expanded", () => { - cy.injectAxe(); - - cy.createSpace({ - name: "Child Space", - initial_state: [], - }).then((spaceId) => { - cy.createSpace({ - name: "Root Space", - initial_state: [spaceChildInitialState(spaceId)], - }).as("spaceId"); - }); - - // Find collapsed Space panel - cy.findByRole("tree", { name: "Spaces" }).within(() => { - cy.findByRole("button", { name: "Root Space" }).should("exist"); - cy.findByRole("button", { name: "Child Space" }).should("not.exist"); - }); - - const axeOptions = { - rules: { - // Disable this check as it triggers on nested roving tab index elements which are in practice fine - "nested-interactive": { - enabled: false, - }, - }, - }; - cy.checkA11y(undefined, axeOptions); - cy.get(".mx_SpacePanel").percySnapshotElement("Space panel collapsed", { widths: [68] }); - - cy.findByRole("tree", { name: "Spaces" }).within(() => { - // This finds the expand button with the class name "mx_SpaceButton_toggleCollapse". Note there is another - // button with the same name with different class name "mx_SpacePanel_toggleCollapse". - cy.findByRole("button", { name: "Expand" }).realHover().click(); - }); - cy.get(".mx_SpacePanel:not(.collapsed)").should("exist"); // TODO: replace :not() selector - - cy.contains(".mx_SpaceItem", "Root Space") - .should("exist") - .contains(".mx_SpaceItem", "Child Space") - .should("exist"); - - cy.checkA11y(undefined, axeOptions); - cy.get(".mx_SpacePanel").percySnapshotElement("Space panel expanded", { widths: [258] }); - }); - - it("should not soft crash when joining a room from space hierarchy which has a link in its topic", () => { - cy.getBot(homeserver, { displayName: "BotBob" }).then({ timeout: 10000 }, async (bot) => { - const { room_id: roomId } = await bot.createRoom({ - preset: "public_chat" as Preset, - name: "Test Room", - topic: "This is a topic https://github.com/matrix-org/matrix-react-sdk/pull/10060 with a link", - }); - const { room_id: spaceId } = await bot.createRoom(spaceCreateOptions("Test Space", [roomId])); - await bot.invite(spaceId, user.userId); - }); - - cy.getSpacePanelButton("Test Space").should("exist"); - cy.wait(500); // without this we can end up clicking too quickly and it ends up having no effect - cy.viewSpaceByName("Test Space"); - cy.findByRole("button", { name: "Accept" }).click(); - - // Regex pattern due to strings in "mx_SpaceHierarchy_roomTile_item" - cy.findByRole("button", { name: /Test Room/ }).realHover(); - cy.findByRole("button", { name: "Join" }).should("exist").realHover().click(); - cy.findByRole("button", { name: "View", timeout: 5000 }).should("exist").realHover().click(); - - // Assert we get shown the new room intro, and thus not the soft crash screen - cy.get(".mx_NewRoomIntro").should("exist"); - }); -}); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts deleted file mode 100644 index 507fc2d75fb..00000000000 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ /dev/null @@ -1,511 +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; - -enum Filter { - People = "people", - PublicRooms = "public_rooms", -} - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - /** - * Opens the spotlight dialog - */ - openSpotlightDialog( - options?: Partial, - ): Chainable>; - spotlightDialog( - options?: Partial, - ): Chainable>; - spotlightFilter( - filter: Filter | null, - options?: Partial, - ): Chainable>; - spotlightSearch( - options?: Partial, - ): Chainable>; - spotlightResults( - options?: Partial, - ): Chainable>; - roomHeaderName( - options?: Partial, - ): Chainable>; - startDM(name: string): Chainable; - } - } -} - -Cypress.Commands.add( - "openSpotlightDialog", - (options?: Partial): Chainable> => { - cy.get(".mx_RoomSearch_spotlightTrigger", options).click({ force: true }); - return cy.spotlightDialog(options); - }, -); - -Cypress.Commands.add( - "spotlightDialog", - (options?: Partial): Chainable> => { - return cy.get('[role=dialog][aria-label="Search Dialog"]', options); - }, -); - -Cypress.Commands.add( - "spotlightFilter", - ( - filter: Filter | null, - options?: Partial, - ): Chainable> => { - 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; - } - return cy.get(selector, options).click(); - }, -); - -Cypress.Commands.add( - "spotlightSearch", - (options?: Partial): Chainable> => { - return cy.get(".mx_SpotlightDialog_searchBox", options).findByRole("textbox", { name: "Search" }); - }, -); - -Cypress.Commands.add( - "spotlightResults", - (options?: Partial): Chainable> => { - return cy.get(".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", options); - }, -); - -Cypress.Commands.add( - "roomHeaderName", - (options?: Partial): Chainable> => { - return cy.get(".mx_RoomHeader_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_RoomHeader").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, publicrooms should be highlighted (because there are no other suggestions) - cy.get("#mx_SpotlightDialog_button_explorePublicRooms").should("have.attr", "aria-selected", "true"); - - // hitting enter should enable the publicrooms filter - 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"); - - cy.spotlightSearch().type("{downArrow}"); - cy.spotlightSearch().type("{downArrow}"); - cy.get("#mx_SpotlightDialog_button_startChat").should("have.attr", "aria-selected", "true"); - cy.spotlightSearch().type("{enter}"); - cy.get(".mx_SpotlightDialog_filter").should("contain", "People"); - cy.spotlightSearch().type("{backspace}"); - cy.get(".mx_SpotlightDialog_filter").should("not.exist"); - }); - }); - - it("should find joined rooms", () => { - cy.openSpotlightDialog() - .within(() => { - 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.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.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.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.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.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.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); - }); - }); - - it("should find unknown people", () => { - cy.openSpotlightDialog() - .within(() => { - 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.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.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.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.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.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.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 335c87bc01e..00000000000 --- a/cypress/e2e/threads/threads.spec.ts +++ /dev/null @@ -1,534 +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); - }); - - it("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"; - - // --MessageTimestamp-color = #acacac = rgb(172, 172, 172) - // See: _MessageTimestamp.pcss - const MessageTimestampColor = "rgb(172, 172, 172)"; - 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}"); - - // Check the colour of timestamp on the main timeline - cy.get(".mx_EventTile_last .mx_EventTile_line .mx_MessageTimestamp").should( - "have.css", - "color", - MessageTimestampColor, - ); - - // 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_image").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_image").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_image").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_image").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}"); - - // Check the colour of timestamp on EventTile in a thread (mx_ThreadView) - cy.get(".mx_EventTile_last[data-layout='group'] .mx_EventTile_line .mx_MessageTimestamp").should( - "have.css", - "color", - MessageTimestampColor, - ); - }); - - // 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_RoomHeader_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"); - - // Check the colour of timestamp on thread list - cy.get(".mx_EventTile_details .mx_MessageTimestamp").should("have.css", "color", MessageTimestampColor); - - // 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 8d9d8afbd29..00000000000 --- a/cypress/e2e/timeline/timeline.spec.ts +++ /dev/null @@ -1,1063 +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 } from "matrix-js-sdk/src/@types/requests"; -import type { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; -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_image").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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary[data-layout=irc] " + - ".mx_GenericEventListSummary_summary", - "created and configured the room.", - ).should("exist"); - - cy.get(".mx_IRCLayout").within(() => { - // Check room name line-height is reset - cy.get(".mx_NewRoomIntro h2").should("have.css", "line-height", "normal"); - - // Check the profile resizer's place - // See: _IRCLayout - // --RoomView_MessageList-padding = 18px (See: _RoomView.pcss) - // --MessageTimestamp-width = 46px (See: _MessageTimestamp.pcss) - // --icon-width = 14px - // --right-padding = 5px - // --name-width = 80px - // --resizer-width = 15px - // --resizer-a11y = 3px - // 18px + 46px + 14px + 5px + 80px + 5px - 15px - 3px - // = 150px - cy.get(".mx_ProfileResizer").should("have.css", "inset-inline-start", "150px"); - }); - - 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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary .mx_GenericEventListSummary_summary", - "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"); - }); - - // Check the height of expanded GELS line - cy.get(".mx_GenericEventListSummary[data-layout=irc] .mx_GenericEventListSummary_spacer").should( - "have.css", - "line-height", - "18px", // var(--irc-line-height): $font-18px (See: _IRCLayout.pcss) - ); - - 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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary .mx_GenericEventListSummary_summary", - "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"); - }); - - // Check the height of expanded GELS line - cy.get(".mx_GenericEventListSummary[data-layout=group] .mx_GenericEventListSummary_spacer").should( - "have.css", - "line-height", - "22px", // $font-22px (See: _GenericEventListSummary.pcss) - ); - - 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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary[data-layout=bubble] " + - ".mx_GenericEventListSummary_summary", - "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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary " + ".mx_GenericEventListSummary_summary", - "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_RoomView_body .mx_EventTile .mx_EventTile_line", "Message") - .realHover() - .within(() => { - cy.findByRole("button", { name: "Edit" }).click(); - cy.get(".mx_BasicMessageComposer_input").type("Edit{enter}"); - }); - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "MessageEdit").should("exist"); - }; - - 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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary .mx_GenericEventListSummary_summary", - "created and configured the room.", - ).should("exist"); - - // Send messages - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").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 .mx_BasicMessageComposer_input").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); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // IRC layout - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); - - // Wait until configuration is finished - cy.contains( - ".mx_RoomView_body .mx_GenericEventListSummary[data-layout=irc] .mx_GenericEventListSummary_summary", - "created and configured the room.", - ).should("exist"); - - cy.get(".mx_RoomView_body[data-layout=irc]").within(() => { - // Ensure CSS declarations which cannot be detected with a screenshot test are applied as expected - cy.get(".mx_EventTile") - .should("have.css", "max-width", "100%") - .should("have.css", "clear", "both") - .should("have.css", "position", "relative"); - - // Check mx_EventTile_continuation - // Block start padding of the second message should not be overridden - cy.get(".mx_EventTile_continuation").should("have.css", "padding-block-start", "0px"); - cy.get(".mx_EventTile_continuation .mx_EventTile_line").should("have.css", "clear", "both"); - - // Select the last event tile - cy.get(".mx_EventTile_last") - .within(() => { - // The last tile is also a continued one - cy.get(".mx_EventTile_line").should("have.css", "clear", "both"); - }) - // Check that zero block padding is set - .should("have.css", "padding-block-start", "0px"); - }); - - 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(() => { - // Ensure CSS declarations which cannot be detected with a screenshot test are applied as expected - cy.get(".mx_EventTile") - .should("have.css", "max-width", "100%") - .should("have.css", "clear", "both") - .should("have.css", "position", "relative"); - - // Check mx_EventTile_continuation - // Block start padding of the second message should not be overridden - cy.get(".mx_EventTile_continuation").should("have.css", "padding-block-start", "0px"); - cy.get(".mx_EventTile_continuation .mx_EventTile_line").should("have.css", "clear", "both"); - - // 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(() => { - // Ensure CSS declarations which cannot be detected with a screenshot test are applied as expected - cy.get(".mx_EventTile") - .should("have.css", "max-width", "100%") - .should("have.css", "clear", "both") - .should("have.css", "position", "relative"); - - // Check cascading works - cy.get(".mx_EventTile_continuation").should("have.css", "padding-block-start", "0px"); - - // 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_RoomView_body[data-layout=bubble]").within(() => { - // Ensure CSS declarations which cannot be detected with a screenshot test are applied as expected - cy.get(".mx_EventTile") - .should("have.css", "max-width", "none") - .should("have.css", "clear", "both") - .should("have.css", "position", "relative"); - - // Check that block start padding of the second message is not overridden - cy.get(".mx_EventTile.mx_EventTile_continuation").should("have.css", "margin-block-start", "2px"); - - // Select the last bubble - cy.get(".mx_EventTile_last") - .within(() => { - // calc(var(--gutterSize) - 1px) - cy.get(".mx_EventTile_line").should("have.css", "padding-block-start", "10px"); - }) - .should("have.css", "margin-block-start", "2px"); // The last bubble is also a continued one - }); - - 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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary .mx_GenericEventListSummary_summary", - "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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary " + ".mx_GenericEventListSummary_summary", - "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(() => { - cy.contains(".mx_MFileBody_info_filename", "1.12 KB").should("exist"); // actual file size in kibibytes - }); - }); - - it("should highlight search result words regardless of formatting", () => { - sendEvent(roomId); - sendEvent(roomId, true); - cy.visit("/#/room/" + roomId); - - cy.get(".mx_RoomHeader").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 input").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 url previews", () => { - cy.intercept("**/_matrix/media/r0/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*", { - statusCode: 200, - fixture: "riot.png", - headers: { - "Content-Type": "image/png", - }, - }).as("mxc"); - cy.intercept("**/_matrix/media/r0/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").should("contain.text", "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("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.contains(".mx_RoomView_body .mx_EventTile_line", "Hello world") - .realHover() - .within(() => { - cy.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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary .mx_GenericEventListSummary_summary", - "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.contains( - ".mx_GenericEventListSummary .mx_EventTile_info.mx_EventTile_last", - "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.contains( - ".mx_RoomView_body .mx_GenericEventListSummary .mx_GenericEventListSummary_summary", - "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/toasts/analytics-toast.spec.ts b/cypress/e2e/toasts/analytics-toast.spec.ts deleted file mode 100644 index 4cc8baa838e..00000000000 --- a/cypress/e2e/toasts/analytics-toast.spec.ts +++ /dev/null @@ -1,98 +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 Chainable = Cypress.Chainable; - -function assertNoToasts(): void { - cy.get(".mx_Toast_toast").should("not.exist"); -} - -function getToast(expectedTitle: string): Chainable { - return cy.contains(".mx_Toast_toast h2", expectedTitle).should("exist").closest(".mx_Toast_toast"); -} - -function acceptToast(expectedTitle: string): void { - getToast(expectedTitle).within(() => { - cy.get(".mx_Toast_buttons .mx_AccessibleButton_kind_primary").click(); - }); -} - -function rejectToast(expectedTitle: string): void { - getToast(expectedTitle).within(() => { - cy.get(".mx_Toast_buttons .mx_AccessibleButton_kind_danger_outline").click(); - }); -} - -describe("Analytics Toast", () => { - let homeserver: HomeserverInstance; - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should not show an analytics toast if config has nothing about posthog", () => { - cy.intercept("/config.json?cachebuster=*", (req) => { - req.continue((res) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { posthog, ...body } = res.body; - res.send(200, body); - }); - }); - - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Tod"); - }); - - rejectToast("Notifications"); - assertNoToasts(); - }); - - describe("with posthog enabled", () => { - beforeEach(() => { - cy.intercept("/config.json?cachebuster=*", (req) => { - req.continue((res) => { - res.send(200, { - ...res.body, - posthog: { - project_api_key: "foo", - api_host: "bar", - }, - }); - }); - }); - - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Tod"); - rejectToast("Notifications"); - }); - }); - - it("should show an analytics toast which can be accepted", () => { - acceptToast("Help improve Element"); - assertNoToasts(); - }); - - it("should show an analytics toast which can be rejected", () => { - rejectToast("Help improve Element"); - assertNoToasts(); - }); - }); -}); diff --git a/cypress/e2e/update/update.spec.ts b/cypress/e2e/update/update.spec.ts deleted file mode 100644 index 99a8fb32a9b..00000000000 --- a/cypress/e2e/update/update.spec.ts +++ /dev/null @@ -1,55 +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"; - -describe("Update", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should navigate to ?updated=$VERSION if realises it is immediately out of date on load", () => { - const NEW_VERSION = "some-new-version"; - - cy.intercept("/version*", { - statusCode: 200, - body: NEW_VERSION, - headers: { - "Content-Type": "test/plain", - }, - }).as("version"); - - cy.initTestUser(homeserver, "Ursa"); - - cy.wait("@version"); - cy.url() - .should("contain", "updated=" + NEW_VERSION) - .then((href) => { - const url = new URL(href); - expect(url.searchParams.get("updated")).to.equal(NEW_VERSION); - }); - }); -}); diff --git a/cypress/e2e/user-menu/user-menu.spec.ts b/cypress/e2e/user-menu/user-menu.spec.ts deleted file mode 100644 index 52bb67c0b7f..00000000000 --- a/cypress/e2e/user-menu/user-menu.spec.ts +++ /dev/null @@ -1,55 +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 type { UserCredentials } from "../../support/login"; - -describe("User Menu", () => { - let homeserver: HomeserverInstance; - let user: UserCredentials; - - const USER_NAME = "Jeff"; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, USER_NAME).then((credentials) => { - user = credentials; - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should contain our name & userId", () => { - cy.findByRole("button", { name: "User menu" }).click(); - - cy.get(".mx_UserMenu_contextMenu").within(() => { - cy.get(".mx_UserMenu_contextMenu_displayName").within(() => { - cy.findByText(USER_NAME).should("exist"); - }); - - cy.get(".mx_UserMenu_contextMenu_userId").within(() => { - cy.findByText(user.userId).should("exist"); - }); - }); - }); -}); diff --git a/cypress/e2e/user-onboarding/user-onboarding-new.spec.ts b/cypress/e2e/user-onboarding/user-onboarding-new.spec.ts deleted file mode 100644 index 5d79c9c58eb..00000000000 --- a/cypress/e2e/user-onboarding/user-onboarding-new.spec.ts +++ /dev/null @@ -1,96 +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"; - -describe("User Onboarding (new user)", () => { - let homeserver: HomeserverInstance; - - const bot1Name = "BotBob"; - let bot1: MatrixClient; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Jane Doe"); - cy.window({ log: false }).then((win) => { - win.localStorage.setItem("mx_registration_time", "1656633601"); - }); - cy.reload().then(() => { - // wait for the app to load - return cy.get(".mx_MatrixChat", { timeout: 15000 }); - }); - cy.getBot(homeserver, { displayName: bot1Name }).then((_bot1) => { - bot1 = _bot1; - }); - cy.get(".mx_UserOnboardingPage").should("exist"); - cy.findByRole("button", { name: "Welcome" }).should("exist"); - cy.get(".mx_UserOnboardingList") - .should("exist") - .should(($list) => { - const list = $list.get(0); - expect(getComputedStyle(list).opacity).to.be.eq("1"); - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("page is shown and preference exists", () => { - cy.get(".mx_UserOnboardingPage").percySnapshotElement("User onboarding page"); - cy.openUserSettings("Preferences"); - cy.findByText("Show shortcut to welcome checklist above the room list").should("exist"); - }); - - it("app download dialog", () => { - cy.findByRole("button", { name: "Download apps" }).click(); - cy.get("[role=dialog]").get("#mx_BaseDialog_title").findByText("Download Element").should("exist"); - cy.get("[role=dialog]").percySnapshotElement("App download dialog", { - widths: [640], - }); - }); - - it("using find friends action should increase progress", () => { - cy.get(".mx_ProgressBar") - .invoke("val") - .then((oldProgress) => { - const findPeopleAction = cy.findByRole("button", { name: "Find friends" }); - expect(findPeopleAction).to.exist; - findPeopleAction.click(); - cy.get(".mx_InviteDialog_editor").findByRole("textbox").type(bot1.getUserId()); - cy.findByRole("button", { name: "Go" }).click(); - cy.get(".mx_InviteDialog_buttonAndSpinner").should("not.exist"); - const message = "Hi!"; - cy.findByRole("textbox", { name: "Send a message…" }).type(`${message}{enter}`); - cy.get(".mx_MTextBody.mx_EventTile_content").findByText(message); - cy.visit("/#/home"); - cy.get(".mx_UserOnboardingPage").should("exist"); - cy.findByRole("button", { name: "Welcome" }).should("exist"); - cy.get(".mx_UserOnboardingList") - .should("exist") - .should(($list) => { - const list = $list.get(0); - expect(getComputedStyle(list).opacity).to.be.eq("1"); - }); - cy.get(".mx_ProgressBar").invoke("val").should("be.greaterThan", oldProgress); - }); - }); -}); diff --git a/cypress/e2e/user-onboarding/user-onboarding-old.spec.ts b/cypress/e2e/user-onboarding/user-onboarding-old.spec.ts deleted file mode 100644 index 502bdd2a0a3..00000000000 --- a/cypress/e2e/user-onboarding/user-onboarding-old.spec.ts +++ /dev/null @@ -1,49 +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"; - -describe("User Onboarding (old user)", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Jane Doe"); - cy.window({ log: false }).then((win) => { - win.localStorage.setItem("mx_registration_time", "2"); - }); - cy.reload().then(() => { - // wait for the app to load - return cy.get(".mx_MatrixChat", { timeout: 15000 }); - }); - }); - }); - - afterEach(() => { - cy.visit("/#/home"); - cy.stopHomeserver(homeserver); - }); - - it("page and preference are hidden", () => { - cy.get(".mx_UserOnboardingPage").should("not.exist"); - cy.get(".mx_UserOnboardingButton").should("not.exist"); - cy.openUserSettings("Preferences"); - cy.findByText(/Show shortcut to welcome page above the room list/).should("not.exist"); - }); -}); diff --git a/cypress/e2e/user-view/user-view.spec.ts b/cypress/e2e/user-view/user-view.spec.ts deleted file mode 100644 index 2acfc9d5355..00000000000 --- a/cypress/e2e/user-view/user-view.spec.ts +++ /dev/null @@ -1,53 +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"; - -describe("UserView", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Violet"); - cy.getBot(homeserver, { displayName: "Usman" }).as("bot"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should render the user view as expected", () => { - cy.get("@bot").then((bot) => { - cy.visit(`/#/user/${bot.getUserId()}`); - }); - - cy.get(".mx_RightPanel .mx_UserInfo_profile h2").within(() => { - cy.findByText("Usman").should("exist"); - }); - - cy.get(".mx_RightPanel").percySnapshotElement("User View", { - // Hide the MXID field as it'll vary on each test - percyCSS: ".mx_UserInfo_profile_mxid { visibility: hidden !important; }", - widths: [260, 500], - }); - }); -}); diff --git a/cypress/e2e/widgets/events.spec.ts b/cypress/e2e/widgets/events.spec.ts deleted file mode 100644 index a3c98b4f6fd..00000000000 --- a/cypress/e2e/widgets/events.spec.ts +++ /dev/null @@ -1,223 +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, Room } 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 - - - - - - -`; - -function waitForRoom(win: Cypress.AUTWindow, roomId: string, predicate: (room: Room) => boolean): Promise { - const matrixClient = win.mxMatrixClientPeg.get(); - - 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); - }); -} - -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, 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, 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 0f18ce85c22..00000000000 --- 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_AppsContainer_resizerHandle") - .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 ca717947d0b..00000000000 --- 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/global.d.ts b/cypress/global.d.ts deleted file mode 100644 index da5b3b8cd71..00000000000 --- a/cypress/global.d.ts +++ /dev/null @@ -1,73 +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"; -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 5ed572f2713..00000000000 --- a/cypress/plugins/dendritedocker/index.ts +++ /dev/null @@ -1,206 +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"; - -// 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(template: string): Promise { - return containerStart(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-monolith-server"; - 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/docker/index.ts b/cypress/plugins/docker/index.ts deleted file mode 100644 index 66bab0b8532..00000000000 --- a/cypress/plugins/docker/index.ts +++ /dev/null @@ -1,171 +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", - ...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")); - }); - }); -} - -/** - * @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 1971a70c5b0..00000000000 --- a/cypress/plugins/index.ts +++ /dev/null @@ -1,38 +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 { 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) { - docker(on, config); - synapseDocker(on, config); - dendriteDocker(on, config); - slidingSyncProxyDocker(on, config); - webserver(on, config); - log(on, config); -} diff --git a/cypress/plugins/sliding-sync/index.ts b/cypress/plugins/sliding-sync/index.ts deleted file mode 100644 index ab39c7a42b7..00000000000 --- 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 3615e4d5117..00000000000 --- a/cypress/plugins/synapsedocker/index.ts +++ /dev/null @@ -1,176 +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 } from "../docker"; -import { HomeserverConfig, HomeserverInstance } from "../utils/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(template: string): Promise { - 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-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 - console.log(`Gen ${path.join(templateDir, "homeserver.yaml")}`); - let hsYaml = await fse.readFile(path.join(templateDir, "homeserver.yaml"), "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); - await fse.writeFile(path.join(tempDir, "homeserver.yaml"), 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); - console.log(`Gen ${path.join(templateDir, "localhost.signing.key")}`); - await fse.writeFile(path.join(tempDir, "localhost.signing.key"), `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 -async function synapseStart(template: string): Promise { - const synCfg = await cfgDirFromTemplate(template); - - console.log(`Starting synapse with config dir ${synCfg.configDir}...`); - - const synapseId = await dockerRun({ - image: "matrixdotorg/synapse:develop", - containerName: `react-sdk-cypress-synapse`, - params: ["--rm", "-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`], - 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/webserver.ts b/cypress/plugins/webserver.ts deleted file mode 100644 index 55a25a313e3..00000000000 --- 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 3e9d75173a2..00000000000 --- 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 38a297fe182..00000000000 --- a/cypress/support/axe.ts +++ /dev/null @@ -1,105 +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 { 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, - }, - // link-in-text-block also complains due to known contrast issues - "link-in-text-block": { - 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 00000000000..ab167ced5b3 --- /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 new file mode 100644 index 00000000000..5b0bf29b976 --- /dev/null +++ b/playwright/e2e/crypto/utils.ts @@ -0,0 +1,322 @@ +/* +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 { expect, JSHandle, type Page } from "@playwright/test"; + +import type { CryptoEvent, ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { + EmojiMapping, + ShowSasCallbacks, + VerificationRequest, + Verifier, + VerifierEvent, +} from "matrix-js-sdk/src/crypto-api"; +import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; +import { Client } from "../../pages/client"; +import { ElementAppPage } from "../../pages/ElementAppPage"; + +/** + * 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) => { + const onVerificationRequestEvent = async (request: VerificationRequest) => { + await request.accept(); + resolve(request); + }; + cli.once( + "crypto.verificationRequestReceived" as CryptoEvent.VerificationRequestReceived, + onVerificationRequestEvent, + ); + }); + }); +} + +/** + * Automatically handle a SAS verification + * + * Given a verifier which has already been started, wait for the emojis to be received, blindly confirm they + * match, and return them + * + * @param verifier - verifier + * @returns A promise that resolves, with the emoji list, once we confirm the emojis + */ +export function handleSasVerification(verifier: JSHandle): Promise { + return verifier.evaluate((verifier) => { + const event = verifier.getShowSasCallbacks(); + if (event) return event.sas.emoji; + + return new Promise((resolve) => { + const onShowSas = (event: ShowSasCallbacks) => { + verifier.off("show_sas" as VerifierEvent, onShowSas); + event.confirm(); + resolve(event.sas.emoji); + }; + + verifier.on("show_sas" as VerifierEvent, onShowSas); + }); + }); +} + +/** + * Check that the user has published cross-signing keys, and that the user's device has been cross-signed. + */ +export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise { + 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. + * + * If a `securityKey` is given, verifies the new device using the key. + */ +export async function logIntoElement( + page: Page, + homeserver: HomeserverInstance, + credentials: Credentials, + securityKey?: string, +) { + await page.goto("/#/login"); + + // select homeserver + await page.getByRole("button", { name: "Edit" }).click(); + await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + + // wait for the dialog to go away + await expect(page.locator(".mx_ServerPickerDialog")).not.toBeVisible(); + + await page.getByRole("textbox", { name: "Username" }).fill(credentials.userId); + await page.getByPlaceholder("Password").fill(credentials.password); + await page.getByRole("button", { name: "Sign in" }).click(); + + // if a securityKey was given, verify the new device + if (securityKey !== undefined) { + await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click(); + // Fill in the security key + await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); + await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + await page.getByRole("button", { name: "Done" }).click(); + } +} + +/** + * Click the "sign out" option in Element, and wait for the login page to load + * + * @param page - Playwright `Page` object. + * @param discardKeys - if true, expect a "You'll lose access to your encrypted messages" dialog, and dismiss it. + */ +export async function logOutOfElement(page: Page, discardKeys: boolean = false) { + await page.getByRole("button", { name: "User menu" }).click(); + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click(); + if (discardKeys) { + await page.getByRole("button", { name: "I don't want my encrypted messages" }).click(); + } else { + await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click(); + } + + // Wait for the login page to load + await page.getByRole("heading", { name: "Sign in" }).click(); +} + +/** + * Open the security settings, and verify the current session using the security key. + * + * @param app - `ElementAppPage` wrapper for the playwright `Page`. + * @param securityKey - The security key (i.e., 4S key), set up during a previous session. + */ +export async function verifySession(app: ElementAppPage, securityKey: string) { + const settings = await app.settings.openUserSettings("Security & Privacy"); + await settings.getByRole("button", { name: "Verify this session" }).click(); + await app.page.getByRole("button", { name: "Verify with Security Key" }).click(); + await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); + await app.page.getByRole("button", { name: "Continue", disabled: false }).click(); + await app.page.getByRole("button", { name: "Done" }).click(); +} + +/** + * Given a SAS verifier for a bot client: + * - wait for the bot to receive the emojis + * - check that the bot sees the same emoji as the application + * + * @param verifier - a verifier in a bot client + */ +export async function doTwoWaySasVerification(page: Page, verifier: JSHandle): 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; +} + +/** + * Send a message in the current room + * @param page + * @param message - The message text to send + */ +export async function sendMessageInCurrentRoom(page: Page, message: string): Promise { + await page.locator(".mx_MessageComposer").getByRole("textbox").fill(message); + await page.getByTestId("sendmessagebtn").click(); +} + +/** + * Create a room with the given name and encryption status using the room creation dialog. + * + * @param roomName - The name of the room to create + * @param isEncrypted - Whether the room should be encrypted + */ +export async function createRoom(page: Page, roomName: string, isEncrypted: boolean): Promise { + await page.getByRole("button", { name: "Add room" }).click(); + await page.locator(".mx_IconizedContextMenu").getByRole("menuitem", { name: "New room" }).click(); + + const dialog = page.locator(".mx_Dialog"); + + await dialog.getByLabel("Name").fill(roomName); + + if (!isEncrypted) { + // it's enabled by default + await page.getByLabel("Enable end-to-end encryption").click(); + } + + await dialog.getByRole("button", { name: "Create room" }).click(); + + // Wait for the client to process the encryption event before carrying on (and potentially sending events). + if (isEncrypted) { + await expect(page.getByText("Encryption enabled")).toBeVisible(); + } +} diff --git a/playwright/e2e/crypto/verification.spec.ts b/playwright/e2e/crypto/verification.spec.ts new file mode 100644 index 00000000000..e471b6b2f52 --- /dev/null +++ b/playwright/e2e/crypto/verification.spec.ts @@ -0,0 +1,408 @@ +/* +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 Preset, type Visibility } from "matrix-js-sdk/src/matrix"; + +import type { JSHandle, Locator, Page } from "@playwright/test"; +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_" }, + room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + + // the other user creates a DM + const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); + + // accept the DM + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Start chatting" }).click(); + await use({ roomId: dmRoomId }); + }, + }); + + test("can receive a verification request when there is no existing DM", async ({ + page, + bot: bob, + user: aliceCredentials, + toasts, + room: { roomId: dmRoomId }, + }) => { + // once Alice has joined, Bob starts the verification + const bobVerificationRequest = await bob.evaluateHandle( + async (client, { dmRoomId, aliceCredentials }) => { + const room = client.getRoom(dmRoomId); + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + room.once(window.matrixcs.RoomStateEvent.Members, resolve); + }); + } + + return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + }, + { dmRoomId, aliceCredentials }, + ); + + // there should also be a toast + const toast = await toasts.getToast("Verification requested"); + // it should contain the details of the requesting user + await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible(); + // Accept + await toast.getByRole("button", { name: "Verify 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(); + }); + + test("can abort emoji verification when emoji mismatch", async ({ + page, + bot: bob, + user: aliceCredentials, + toasts, + room: { roomId: dmRoomId }, + cryptoBackend, + }) => { + test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto"); + + // once Alice has joined, Bob starts the verification + const bobVerificationRequest = await bob.evaluateHandle( + async (client, { dmRoomId, aliceCredentials }) => { + const room = client.getRoom(dmRoomId); + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + room.once(window.matrixcs.RoomStateEvent.Members, resolve); + }); + } + + return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + }, + { dmRoomId, aliceCredentials }, + ); + + // Accept verification via toast + const toast = await toasts.getToast("Verification requested"); + await toast.getByRole("button", { name: "Verify 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()).catch(() => {}); + // ... and abort the verification + await page.getByRole("button", { name: "They don't match" }).click(); + + const dialog = page.locator(".mx_Dialog"); + await expect(dialog.getByText("Your messages are not secure")).toBeVisible(); + await dialog.getByRole("button", { name: "OK" }).click(); + await expect(dialog).not.toBeVisible(); + }); +}); + +/** 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 new file mode 100644 index 00000000000..7ed5d136fe3 --- /dev/null +++ b/playwright/e2e/editing/editing.spec.ts @@ -0,0 +1,374 @@ +/* +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 { Locator, Page } from "@playwright/test"; + +import type { EventType, IContent, ISendEventResponse, MsgType, Visibility } from "matrix-js-sdk/src/matrix"; +import { expect, test } from "../../element-web-test"; +import { ElementAppPage } from "../../pages/ElementAppPage"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; + +async function sendEvent(app: ElementAppPage, roomId: string): Promise { + return app.client.sendEvent(roomId, null, "m.room.message" as EventType, { + msgtype: "m.text" as MsgType, + body: "Message", + }); +} + +/** generate a message event which will take up some room on the page. */ +function mkPadding(n: number): IContent { + return { + msgtype: "m.text" as MsgType, + body: `padding ${n}`, + format: "org.matrix.custom.html", + formatted_body: `

Test event ${n}

\n`.repeat(10), + }; +} + +test.describe("Editing", () => { + // Edit "Message" + const editLastMessage = async (page: Page, edit: string) => { + const eventTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last"); + await eventTile.hover(); + await eventTile.getByRole("button", { name: "Edit" }).click(); + + const textbox = page.getByRole("textbox", { name: "Edit message" }); + await textbox.fill(edit); + await textbox.press("Enter"); + }; + + const clickEditedMessage = async (page: Page, edited: string) => { + // Assert that the message was edited + const eventTile = page.locator(".mx_EventTile", { hasText: edited }); + await expect(eventTile).toBeVisible(); + // Click to display the message edit history dialog + await eventTile.getByText("(edited)").click(); + }; + + const clickButtonViewSource = async (locator: Locator) => { + const eventTile = locator.locator(".mx_EventTile_line"); + await eventTile.hover(); + // Assert that "View Source" button is rendered and click it + await eventTile.getByRole("button", { name: "View Source" }).click(); + }; + + test.use({ + displayName: "Edith", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ name: "Test room" }); + await use({ roomId }); + }, + botCreateOpts: { displayName: "Bob" }, + }); + + test("should render and interact with the message edit history dialog", async ({ page, user, app, room }) => { + // Click the "Remove" button on the message edit history dialog + const clickButtonRemove = async (locator: Locator) => { + const eventTileLine = locator.locator(".mx_EventTile_line"); + await eventTileLine.hover(); + await eventTileLine.getByRole("button", { name: "Remove" }).click(); + }; + + await page.goto(`#/room/${room.roomId}`); + + // Send "Message" + await sendEvent(app, room.roomId); + + // Edit "Message" to "Massage" + await editLastMessage(page, "Massage"); + + // Assert that the edit label is visible + await expect(page.locator(".mx_EventTile_edited")).toBeVisible(); + + await clickEditedMessage(page, "Massage"); + + // Assert that the message edit history dialog is rendered + const dialog = page.getByRole("dialog"); + const li = dialog.getByRole("listitem").last(); + // Assert CSS styles which are difficult or cannot be detected with snapshots are applied as expected + await expect(li).toHaveCSS("clear", "both"); + + const timestamp = li.locator(".mx_EventTile .mx_MessageTimestamp"); + await expect(timestamp).toHaveCSS("position", "absolute"); + await expect(timestamp).toHaveCSS("inset-inline-start", "0px"); + await expect(timestamp).toHaveCSS("text-align", "center"); + + // Assert that monospace characters can fill the content line as expected + await expect(li.locator(".mx_EventTile .mx_EventTile_content")).toHaveCSS("margin-inline-end", "0px"); + + // Assert that zero block start padding is applied to mx_EventTile as expected + // See: .mx_EventTile on _EventTile.pcss + await expect(li.locator(".mx_EventTile")).toHaveCSS("padding-block-start", "0px"); + + // Assert that the date separator is rendered at the top + await expect(dialog.getByRole("listitem").first().locator("h2", { hasText: "today" })).toHaveCSS( + "text-transform", + "capitalize", + ); + + { + // Assert that the edited message is rendered under the date separator + const tile = dialog.locator("li:nth-child(2) .mx_EventTile"); + // Assert that the edited message body consists of both deleted character and inserted character + // Above the first "e" of "Message" was replaced with "a" + await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage"); + + const body = tile.locator(".mx_EventTile_content .mx_EventTile_body"); + await expect(body.locator(".mx_EditHistoryMessage_deletion").getByText("e")).toBeVisible(); + await expect(body.locator(".mx_EditHistoryMessage_insertion").getByText("a")).toBeVisible(); + } + + // Assert that the original message is rendered at the bottom + await expect( + dialog + .locator("li:nth-child(3) .mx_EventTile") + .locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Message" }), + ).toBeVisible(); + + // Take a snapshot of the dialog + await expect(dialog).toMatchScreenshot("message-edit-history-dialog.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + + { + const tile = dialog.locator("li:nth-child(2) .mx_EventTile"); + await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage"); + // Click the "Remove" button again + await clickButtonRemove(tile); + } + + // Do nothing and close the dialog to confirm that the message edit history dialog is rendered + await app.closeDialog(); + + { + // Assert that the message edit history dialog is rendered again after it was closed + const tile = dialog.locator("li:nth-child(2) .mx_EventTile"); + await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage"); + // Click the "Remove" button again + await clickButtonRemove(tile); + } + + // This time remove the message really + const textInputDialog = page.locator(".mx_TextInputDialog"); + await textInputDialog.getByRole("textbox", { name: "Reason (optional)" }).fill("This is a test."); // Reason + await textInputDialog.getByRole("button", { name: "Remove" }).click(); + + // Assert that the message edit history dialog is rendered again + const messageEditHistoryDialog = page.locator(".mx_MessageEditHistoryDialog"); + // Assert that the date is rendered + await expect( + messageEditHistoryDialog.getByRole("listitem").first().locator("h2", { hasText: "today" }), + ).toHaveCSS("text-transform", "capitalize"); + + // Assert that the original message is rendered under the date on the dialog + await expect( + messageEditHistoryDialog + .locator("li:nth-child(2) .mx_EventTile") + .locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Message" }), + ).toBeVisible(); + + // Assert that the edited message is gone + await expect( + messageEditHistoryDialog.locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Meassage" }), + ).not.toBeVisible(); + + await app.closeDialog(); + + // Assert that the redaction placeholder is rendered + await expect( + page + .locator(".mx_RoomView_MessageList") + .locator(".mx_EventTile_last .mx_RedactedBody", { hasText: "Message deleted" }), + ).toBeVisible(); + }); + + test("should render 'View Source' button in developer mode on the message edit history dialog", async ({ + page, + user, + app, + room, + }) => { + await page.goto(`#/room/${room.roomId}`); + + // Send "Message" + await sendEvent(app, room.roomId); + + // Edit "Message" to "Massage" + await editLastMessage(page, "Massage"); + + // Assert that the edit label is visible + await expect(page.locator(".mx_EventTile_edited")).toBeVisible(); + + await clickEditedMessage(page, "Massage"); + + { + const dialog = page.getByRole("dialog"); + // Assert that the original message is rendered + const li = dialog.locator("li:nth-child(3)"); + // Assert that "View Source" is not rendered + const eventLine = li.locator(".mx_EventTile_line"); + await eventLine.hover(); + await expect(eventLine.getByRole("button", { name: "View Source" })).not.toBeVisible(); + } + + await app.closeDialog(); + + // Enable developer mode + await app.settings.setValue("developerMode", null, SettingLevel.ACCOUNT, true); + + await clickEditedMessage(page, "Massage"); + + { + const dialog = page.getByRole("dialog"); + { + // Assert that the edited message is rendered + const li = dialog.locator("li:nth-child(2)"); + // Assert that "Remove" button for the original message is rendered + const line = li.locator(".mx_EventTile_line"); + await line.hover(); + await expect(line.getByRole("button", { name: "Remove" })).toBeVisible(); + await clickButtonViewSource(li); + } + + // Assert that view source dialog is rendered and close the dialog + await app.closeDialog(); + + { + // Assert that the original message is rendered + const li = dialog.locator("li:nth-child(3)"); + // Assert that "Remove" button for the original message does not exist + const line = li.locator(".mx_EventTile_line"); + await line.hover(); + await expect(line.getByRole("button", { name: "Remove" })).not.toBeVisible(); + + await clickButtonViewSource(li); + } + + // Assert that view source dialog is rendered and close the dialog + await app.closeDialog(); + } + }); + + test("should close the composer when clicking save after making a change and undoing it", async ({ + page, + user, + app, + room, + axe, + checkA11y, + }) => { + axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here + axe.exclude(".mx_Tooltip_visible"); // XXX: this is fine but would be good to fix + + await page.goto(`#/room/${room.roomId}`); + + await sendEvent(app, room.roomId); + + { + // Edit message + const tile = page.locator(".mx_RoomView_body .mx_EventTile").last(); + await expect(tile.getByText("Message", { exact: true })).toBeVisible(); + const line = tile.locator(".mx_EventTile_line"); + await line.hover(); + await line.getByRole("button", { name: "Edit" }).click(); + await checkA11y(); + const editComposer = page.getByRole("textbox", { name: "Edit message" }); + await editComposer.pressSequentially("Foo"); + await editComposer.press("Backspace"); + await editComposer.press("Backspace"); + await editComposer.press("Backspace"); + await editComposer.press("Enter"); + await app.getComposerField().hover(); // XXX: move the hover to get rid of the "Edit" tooltip + await checkA11y(); + } + await expect( + page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: "Message" }), + ).toBeVisible(); + + // Assert that the edit composer has gone away + await expect(page.getByRole("textbox", { name: "Edit message" })).not.toBeVisible(); + }); + + test("should correctly display events which are edited, where we lack the edit event", async ({ + page, + user, + app, + axe, + checkA11y, + bot: bob, + }) => { + // This tests the behaviour when a message has been edited some time after it has been sent, and we + // jump back in room history to view the event, but do not have the actual edit event. + // + // In that scenario, we rely on the server to replace the content (pre-MSC3925), or do it ourselves based on + // the bundled edit event (post-MSC3925). + // + // To test it, we need to have a room with lots of events in, so we can jump around the timeline without + // paginating in the event itself. Hence, we create a bot user which creates the room and populates it before + // we join. + + // "bob" now creates the room, and sends a load of events in it. Note that all of this happens via calls on + // the js-sdk rather than Cypress commands, so uses regular async/await. + const testRoomId = await bob.createRoom({ name: "TestRoom", visibility: "public" as Visibility }); + + const { event_id: originalEventId } = await bob.sendMessage(testRoomId, { + body: "original", + msgtype: "m.text", + }); + + // send a load of padding events. We make them large, so that they fill the whole screen + // and the client doesn't end up paginating into the event we want. + let i = 0; + while (i < 10) { + await bob.sendMessage(testRoomId, mkPadding(i++)); + } + + // ... then the edit ... + const editEventId = ( + await bob.sendMessage(testRoomId, { + "m.new_content": { body: "Edited body", msgtype: "m.text" }, + "m.relates_to": { + rel_type: "m.replace", + event_id: originalEventId, + }, + "body": "* edited", + "msgtype": "m.text", + }) + ).event_id; + + // ... then a load more padding ... + while (i < 20) { + await bob.sendMessage(testRoomId, mkPadding(i++)); + } + + // now have the cypress user join the room, jump to the original event, and wait for the event to be visible + await app.client.joinRoom(testRoomId); + await app.viewRoomByName("TestRoom"); + await page.goto(`#/room/${testRoomId}/${originalEventId}`); + + const messageTile = page.locator(`[data-event-id="${originalEventId}"]`); + // at this point, the edit event should still be unknown + const timeline = await app.client.evaluate( + (cli, { testRoomId, editEventId }) => cli.getRoom(testRoomId).getTimelineForEvent(editEventId), + { testRoomId, editEventId }, + ); + expect(timeline).toBeNull(); + + // nevertheless, the event should be updated + await expect(messageTile.locator(".mx_EventTile_body")).toHaveText("Edited body"); + await expect(messageTile.locator(".mx_EventTile_edited")).toBeVisible(); + }); +}); diff --git a/playwright/e2e/file-upload/image-upload.spec.ts b/playwright/e2e/file-upload/image-upload.spec.ts new file mode 100644 index 00000000000..8f0403af31c --- /dev/null +++ b/playwright/e2e/file-upload/image-upload.spec.ts @@ -0,0 +1,45 @@ +/* +Copyright 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. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Image Upload", () => { + test.use({ + displayName: "Alice", + }); + + test.beforeEach(async ({ page, app, user }) => { + await app.client.createRoom({ name: "My Pictures" }); + await app.viewRoomByName("My Pictures"); + + // Wait until configuration is finished + await expect( + page + .locator(".mx_GenericEventListSummary[data-layout='group'] .mx_GenericEventListSummary_summary") + .getByText(`${user.displayName} created and configured the room.`), + ).toBeVisible(); + }); + + test("should show image preview when uploading an image", async ({ page, app }) => { + await page + .locator(".mx_MessageComposer_actions input[type='file']") + .setInputFiles("playwright/sample-files/riot.png"); + + expect(page.getByRole("button", { name: "Upload" })).toBeEnabled(); + expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled(); + expect(page).toMatchScreenshot("image-upload-preview.png"); + }); +}); 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 00000000000..c107bb2cbcb --- /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 00000000000..b5ca6a1b3a5 --- /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 00000000000..b178596674d --- /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 00000000000..61bad8a3ec7 --- /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/playwright/e2e/integration-manager/utils.ts b/playwright/e2e/integration-manager/utils.ts new file mode 100644 index 00000000000..259ff732c79 --- /dev/null +++ b/playwright/e2e/integration-manager/utils.ts @@ -0,0 +1,25 @@ +/* +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"; + +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/invite/invite-dialog.spec.ts b/playwright/e2e/invite/invite-dialog.spec.ts new file mode 100644 index 00000000000..98a57c8eb1a --- /dev/null +++ b/playwright/e2e/invite/invite-dialog.spec.ts @@ -0,0 +1,135 @@ +/* +Copyright 2023 Suguru Hirahara +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("Invite dialog", function () { + test.use({ + displayName: "Hanako", + botCreateOpts: { + displayName: "BotAlice", + }, + }); + + const botName = "BotAlice"; + + test("should support inviting a user to a room", async ({ page, app, user, bot }) => { + // Create and view a room + await app.client.createRoom({ name: "Test Room" }); + await app.viewRoomByName("Test Room"); + + // Assert that the room was configured + await expect(page.getByText("Hanako created and configured the room.")).toBeVisible(); + + // Open the room info panel + await page.getByRole("button", { name: "Room info" }).click(); + + await page.locator(".mx_BaseCard").getByRole("menuitem", { name: "Invite" }).click(); + + const other = page.locator(".mx_InviteDialog_other"); + // Assert that the header is rendered + await expect( + other.locator(".mx_Dialog_header .mx_Dialog_title").getByText("Invite to Test Room"), + ).toBeVisible(); + // Assert that the bar is rendered + await expect(other.locator(".mx_InviteDialog_addressBar")).toBeVisible(); + + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-room-without-user.png"); + + await expect(other.locator(".mx_InviteDialog_identityServer")).not.toBeVisible(); + + await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId); + + // Assert that notification about identity servers appears after typing userId + await expect(other.locator(".mx_InviteDialog_identityServer")).toBeVisible(); + + // Assert that the bot id is rendered properly + await expect( + other.locator(".mx_InviteDialog_tile_nameStack_userId").getByText(bot.credentials.userId), + ).toBeVisible(); + + await other.locator(".mx_InviteDialog_tile_nameStack_name").getByText(botName).click(); + + await expect( + other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName), + ).toBeVisible(); + + // Take a snapshot of the invite dialog with a user pill + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-room-with-user-pill.png"); + + // Invite the bot + await other.getByRole("button", { name: "Invite" }).click(); + + // Assert that the invite dialog disappears + await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible(); + + // Assert that they were invited and joined + await expect(page.getByText(`${botName} joined the room`)).toBeVisible(); + }); + + test("should support inviting a user to Direct Messages", async ({ page, app, user, bot }) => { + await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click(); + + const other = page.locator(".mx_InviteDialog_other"); + // Assert that the header is rendered + await expect(other.locator(".mx_Dialog_header .mx_Dialog_title").getByText("Direct Messages")).toBeVisible(); + + // Assert that the bar is rendered + await expect(other.locator(".mx_InviteDialog_addressBar")).toBeVisible(); + + // Take a snapshot of the invite dialog + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-dm-without-user.png"); + + await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId); + + await expect(other.locator(".mx_InviteDialog_tile_nameStack").getByText(bot.credentials.userId)).toBeVisible(); + await other.locator(".mx_InviteDialog_tile_nameStack").getByText(botName).click(); + + await expect( + other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName), + ).toBeVisible(); + + // Take a snapshot of the invite dialog with a user pill + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-dm-with-user-pill.png"); + + // Open a direct message UI + await other.getByRole("button", { name: "Go" }).click(); + + // Assert that the invite dialog disappears + await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible(); + + // Assert that the hovered user name on invitation UI does not have background color + // TODO: implement the test on room-header.spec.ts + const roomHeader = page.locator(".mx_LegacyRoomHeader"); + await roomHeader.locator(".mx_LegacyRoomHeader_name--textonly").hover(); + await expect(roomHeader.locator(".mx_LegacyRoomHeader_name--textonly")).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + + // Send a message to invite the bots + const composer = app.getComposer().locator("[contenteditable]"); + await composer.fill("Hello}"); + await composer.press("Enter"); + + // Assert that they were invited and joined + await expect(page.getByText(`${botName} joined the room`)).toBeVisible(); + + // Assert that the message is displayed at the bottom + await expect(page.locator(".mx_EventTile_last").getByText("Hello")).toBeVisible(); + }); +}); 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 00000000000..8763c0fd6a8 --- /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 00000000000..5ee366fcf25 --- /dev/null +++ b/playwright/e2e/knock/knock-into-room.spec.ts @@ -0,0 +1,302 @@ +/* +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: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + ).not.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 00000000000..3fb5c685517 --- /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 00000000000..8b815898136 --- /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/left-panel/left-panel.spec.ts b/playwright/e2e/left-panel/left-panel.spec.ts new file mode 100644 index 00000000000..98e49108548 --- /dev/null +++ b/playwright/e2e/left-panel/left-panel.spec.ts @@ -0,0 +1,31 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("LeftPanel", () => { + test.use({ + displayName: "Hanako", + }); + + test("should render the Rooms list", async ({ page, app, user }) => { + // create rooms and check room names are correct + for (const name of ["Apple", "Pineapple", "Orange"]) { + await app.client.createRoom({ name }); + await expect(page.getByRole("treeitem", { name })).toBeVisible(); + } + }); +}); diff --git a/playwright/e2e/location/location.spec.ts b/playwright/e2e/location/location.spec.ts new file mode 100644 index 00000000000..e3f6120ef34 --- /dev/null +++ b/playwright/e2e/location/location.spec.ts @@ -0,0 +1,67 @@ +/* +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 { Locator, Page } from "@playwright/test"; + +import { test, expect } from "../../element-web-test"; + +test.describe("Location sharing", () => { + const selectLocationShareTypeOption = (page: Page, shareType: string): Locator => { + return page.getByTestId(`share-location-option-${shareType}`); + }; + + const submitShareLocation = (page: Page): Promise => { + return page.getByRole("button", { name: "Share location" }).click(); + }; + + test.use({ + displayName: "Tom", + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("mx_lhs_size", "0"); + }); + }); + + test("sends and displays pin drop location message successfully", async ({ page, user, app }) => { + const roomId = await app.client.createRoom({}); + await page.goto(`/#/room/${roomId}`); + + const composerOptions = await app.openMessageComposerOptions(); + await composerOptions.getByRole("menuitem", { name: "Location", exact: true }).click(); + + await selectLocationShareTypeOption(page, "Pin").click(); + + await page.locator("#mx_LocationPicker_map").click(); + + await submitShareLocation(page); + + await page.locator(".mx_RoomView_body .mx_EventTile .mx_MLocationBody").click({ + position: { + x: 225, + y: 150, + }, + }); + + // clicking location tile opens maximised map + await expect(page.getByRole("dialog")).toBeVisible(); + + await app.closeDialog(); + + await expect(page.locator(".mx_Marker")).toBeVisible(); + }); +}); diff --git a/playwright/e2e/login/consent.spec.ts b/playwright/e2e/login/consent.spec.ts new file mode 100644 index 00000000000..c24a71f8a8a --- /dev/null +++ b/playwright/e2e/login/consent.spec.ts @@ -0,0 +1,53 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Consent", () => { + test.use({ + startHomeserverOpts: "consent", + displayName: "Bob", + }); + + test("should prompt the user to consent to terms when server deems it necessary", async ({ + context, + page, + user, + app, + }) => { + // Attempt to create a room using the js-sdk which should return an error with `M_CONSENT_NOT_GIVEN` + await app.client.createRoom({}).catch(() => {}); + const newPagePromise = context.waitForEvent("page"); + + const dialog = page.locator(".mx_QuestionDialog"); + // Accept terms & conditions + await expect(dialog.getByRole("heading", { name: "Terms and Conditions" })).toBeVisible(); + await page.getByRole("button", { name: "Review terms and conditions" }).click(); + + const newPage = await newPagePromise; + await newPage.locator('[type="submit"]').click(); + await expect(newPage.getByText("Danke schoen")).toBeVisible(); + + // go back to the app + await page.goto("/"); + // wait for the app to re-load + await expect(page.locator(".mx_MatrixChat")).toBeVisible(); + + // attempt to perform the same action again and expect it to not fail + await app.client.createRoom({ name: "Test Room" }); + await expect(page.getByText("Test Room")).toBeVisible(); + }); +}); diff --git a/playwright/e2e/login/login.spec.ts b/playwright/e2e/login/login.spec.ts new file mode 100644 index 00000000000..67cdda642f6 --- /dev/null +++ b/playwright/e2e/login/login.spec.ts @@ -0,0 +1,143 @@ +/* +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 { doTokenRegistration } from "./utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; + +test.describe("Login", () => { + test.describe("m.login.password", () => { + test.use({ startHomeserverOpts: "consent" }); + + const username = "user1234"; + const password = "p4s5W0rD"; + + test.beforeEach(async ({ page, homeserver }) => { + await homeserver.registerUser(username, password); + await page.goto("/#/login"); + }); + + test("logs in with an existing account and lands on the home screen", async ({ + page, + homeserver, + checkA11y, + }) => { + // first pick the homeserver, as otherwise the user picker won't be visible + await page.getByRole("button", { name: "Edit" }).click(); + await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + // wait for the dialog to go away + await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0); + + await expect(page.locator(".mx_Spinner")).toHaveCount(0); + await expect(page.locator(".mx_ServerPicker_server")).toHaveText(homeserver.config.baseUrl); + + await page.getByRole("button", { name: "Edit" }).click(); + + // select the default server again + await page.locator(".mx_StyledRadioButton").first().click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0); + await expect(page.locator(".mx_Spinner")).toHaveCount(0); + // name of default server + await expect(page.locator(".mx_ServerPicker_server")).toHaveText("server.invalid"); + + // switch back to the custom homeserver + await page.getByRole("button", { name: "Edit" }).click(); + await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + // wait for the dialog to go away + await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0); + + await expect(page.locator(".mx_Spinner")).toHaveCount(0); + await expect(page.locator(".mx_ServerPicker_server")).toHaveText(homeserver.config.baseUrl); + + await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible(); + // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 + // cy.percySnapshot("Login"); + await checkA11y(); + + await page.getByRole("textbox", { name: "Username" }).fill(username); + await page.getByPlaceholder("Password").fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); + + await expect(page).toHaveURL(/\/#\/home$/); + }); + }); + + // tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server + test.describe("SSO login", () => { + test.skip(isDendrite, "does not yet support SSO"); + + test.use({ + startHomeserverOpts: ({ oAuthServer }, use) => + use({ + template: "default", + oAuthServerPort: oAuthServer.port, + }), + }); + + test("logs in with SSO and lands on the home screen", async ({ page, homeserver }) => { + // If this test fails with a screen showing "Timeout connecting to remote server", it is most likely due to + // your firewall settings: Synapse is unable to reach the OIDC server. + // + // If you are using ufw, try something like: + // sudo ufw allow in on docker0 + // + await doTokenRegistration(page, homeserver); + }); + }); + + test.describe("logout", () => { + test.use({ startHomeserverOpts: "consent" }); + + test("should go to login page on logout", async ({ page, user }) => { + await page.getByRole("button", { name: "User menu" }).click(); + await expect(page.getByText(user.displayName, { exact: true })).toBeVisible(); + + // Allow the outstanding requests queue to settle before logging out + await page.waitForTimeout(2000); + + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click(); + await expect(page).toHaveURL(/\/#\/login$/); + }); + }); + + test.describe("logout with logout_redirect_url", () => { + test.use({ + startHomeserverOpts: "consent", + config: { + // We redirect to decoder-ring because it's a predictable page that isn't Element itself. + // We could use example.org, matrix.org, or something else, however this puts dependency of external + // infrastructure on our tests. In the same vein, we don't really want to figure out how to ship a + // `test-landing.html` page when running with an uncontrolled Element (via `yarn start`). + // Using the decoder-ring is just as fine, and we can search for strategic names. + logout_redirect_url: "/decoder-ring/", + }, + }); + + test("should respect logout_redirect_url", async ({ page, user }) => { + await page.getByRole("button", { name: "User menu" }).click(); + await expect(page.getByText(user.displayName, { exact: true })).toBeVisible(); + + // give a change for the outstanding requests queue to settle before logging out + await page.waitForTimeout(2000); + + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click(); + await expect(page).toHaveURL(/\/decoder-ring\/$/); + }); + }); +}); diff --git a/playwright/e2e/login/overwrite_login.spec.ts b/playwright/e2e/login/overwrite_login.spec.ts new file mode 100644 index 00000000000..7ef8769c9db --- /dev/null +++ b/playwright/e2e/login/overwrite_login.spec.ts @@ -0,0 +1,54 @@ +/* +Copyright 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. +*/ + +import { test, expect } from "../../element-web-test"; +import { logIntoElement } from "../crypto/utils"; + +test.describe("Overwrite login action", () => { + // This seems terminally flakey: https://github.com/element-hq/element-web/issues/27363 + // I tried verious things to try & deflake it, to no avail: https://github.com/matrix-org/matrix-react-sdk/pull/12506 + test.skip("Try replace existing login with new one", async ({ page, app, credentials, homeserver }) => { + await logIntoElement(page, homeserver, credentials); + + const userMenu = await app.openUserMenu(); + await expect(userMenu.getByText(credentials.userId)).toBeVisible(); + + const bobRegister = await homeserver.registerUser("BobOverwrite", "p@ssword1!", "BOB"); + + // just assert that it's a different user + expect(credentials.userId).not.toBe(bobRegister.userId); + + const clientCredentials /* IMatrixClientCreds */ = { + homeserverUrl: homeserver.config.baseUrl, + ...bobRegister, + }; + + // Trigger the overwrite login action + await app.client.evaluate(async (cli, clientCredentials) => { + // @ts-ignore - raw access to the dispatcher to simulate the action + window.mxDispatcher.dispatch( + { + action: "overwrite_login", + credentials: clientCredentials, + }, + true, + ); + }, clientCredentials); + + // It should be now another user!! + await expect(page.getByText("Welcome BOB")).toBeVisible(); + }); +}); diff --git a/playwright/e2e/login/soft_logout.spec.ts b/playwright/e2e/login/soft_logout.spec.ts new file mode 100644 index 00000000000..a9becad0aae --- /dev/null +++ b/playwright/e2e/login/soft_logout.spec.ts @@ -0,0 +1,130 @@ +/* +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/test"; + +import { test, expect } from "../../element-web-test"; +import { doTokenRegistration } from "./utils"; +import { Credentials } from "../../plugins/homeserver"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; + +test.describe("Soft logout", () => { + test.use({ + displayName: "Alice", + startHomeserverOpts: ({ oAuthServer }, use) => + use({ + template: "default", + oAuthServerPort: oAuthServer.port, + }), + }); + + test.describe("with password user", () => { + test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => { + await interceptRequestsWithSoftLogout(page, user); + await expect(page.getByText("You're signed out")).toBeVisible(); + await page.getByPlaceholder("Password").fill(user.password); + await page.getByPlaceholder("Password").press("Enter"); + + // back to the welcome page + await expect(page).toHaveURL(/\/#\/home/); + await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible(); + }); + + test("still shows the soft-logout page when the page is reloaded after a soft-logout", async ({ + page, + user, + }) => { + await interceptRequestsWithSoftLogout(page, user); + await expect(page.getByText("You're signed out")).toBeVisible(); + await page.reload(); + await expect(page.getByText("You're signed out")).toBeVisible(); + }); + }); + + test.describe("with SSO user", () => { + test.skip(isDendrite, "does not yet support SSO"); + + test.use({ + user: async ({ page, homeserver }, use) => { + const user = await doTokenRegistration(page, homeserver); + + // Eventually, we should end up at the home screen. + await expect(page).toHaveURL(/\/#\/home$/); + await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible(); + + await use(user); + }, + }); + + test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => { + await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible(); + + await interceptRequestsWithSoftLogout(page, user); + + await expect(page.getByText("You're signed out")).toBeVisible(); + await page.getByRole("button", { name: "Continue with OAuth test" }).click(); + + // click the submit button + await page.getByRole("button", { name: "Submit" }).click(); + + // Synapse prompts us to grant permission to Element + await expect(page.getByRole("heading", { name: "Continue to your account" })).toBeVisible(); + await page.getByRole("link", { name: "Continue" }).click(); + + // back to the welcome page + await expect(page).toHaveURL(/\/#\/home$/); + await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible(); + }); + }); +}); + +/** + * Intercept calls to /sync and have them fail with a soft-logout + * + * Any further requests to /sync with the same access token are blocked. + */ +async function interceptRequestsWithSoftLogout(page: Page, user: Credentials): Promise { + await page.route("**/_matrix/client/*/sync*", async (route, req) => { + const accessToken = await req.headerValue("Authorization"); + + // now, if the access token on this request matches the expired one, block it + if (accessToken === `Bearer ${user.accessToken}`) { + console.log("Intercepting request with soft-logged-out access token"); + await route.fulfill({ + status: 401, + json: { + errcode: "M_UNKNOWN_TOKEN", + error: "Soft logout", + soft_logout: true, + }, + }); + return; + } + + // otherwise, pass through as normal + await route.continue(); + }); + + const promise = page.waitForResponse((resp) => resp.url().includes("/sync") && resp.status() === 401); + + // do something to make the active /sync return: create a new room + await page.evaluate(() => { + // don't wait for this to complete: it probably won't, because of the broken sync + window.mxMatrixClientPeg.get().createRoom({}); + }); + + await promise; +} diff --git a/playwright/e2e/login/utils.ts b/playwright/e2e/login/utils.ts new file mode 100644 index 00000000000..2cfc0d452e7 --- /dev/null +++ b/playwright/e2e/login/utils.ts @@ -0,0 +1,68 @@ +/* +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, expect } from "@playwright/test"; + +import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; + +/** Visit the login page, choose to log in with "OAuth test", register a new account, and redirect back to Element + */ +export async function doTokenRegistration( + page: Page, + homeserver: HomeserverInstance, +): Promise { + await page.goto("/#/login"); + + await page.getByRole("button", { name: "Edit" }).click(); + await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl); + await page.getByRole("button", { name: "Continue" }).click(); + // wait for the dialog to go away + await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0); + + // click on "Continue with OAuth test" + await page.getByRole("button", { name: "Continue with OAuth test" }).click(); + + // wait for the Test OAuth Page to load + await expect(page.getByText("Test OAuth page")).toBeVisible(); + + // click the submit button + await page.getByRole("button", { name: "Submit" }).click(); + + // Synapse prompts us to pick a user ID + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + await page.getByRole("textbox", { name: "Username (required)" }).fill("alice"); + + // wait for username validation to start, and complete + await expect(page.locator("#field-username-output")).toHaveText(""); + await page.getByRole("button", { name: "Continue" }).click(); + + // Synapse prompts us to grant permission to Element + page.getByRole("heading", { name: "Continue to your account" }); + await page.getByRole("link", { name: "Continue" }).click(); + + // Eventually, we should end up at the home screen. + await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); + await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible(); + + return page.evaluate(() => ({ + accessToken: window.mxMatrixClientPeg.get().getAccessToken(), + userId: window.mxMatrixClientPeg.get().getUserId(), + deviceId: window.mxMatrixClientPeg.get().getDeviceId(), + homeServer: window.mxMatrixClientPeg.get().getHomeserverUrl(), + password: null, + displayName: "Alice", + })); +} diff --git a/playwright/e2e/oidc/index.ts b/playwright/e2e/oidc/index.ts new file mode 100644 index 00000000000..61b9aa688bf --- /dev/null +++ b/playwright/e2e/oidc/index.ts @@ -0,0 +1,104 @@ +/* +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 { API, Messages } from "mailhog"; +import { Page } from "@playwright/test"; + +import { test as base, expect } from "../../element-web-test"; +import { MatrixAuthenticationService } from "../../plugins/matrix-authentication-service"; +import { StartHomeserverOpts } from "../../plugins/homeserver"; + +export const test = base.extend<{ + masPrepare: MatrixAuthenticationService; + mas: MatrixAuthenticationService; +}>({ + // There's a bit of a chicken and egg problem between MAS & Synapse where they each need to know how to reach each other + // so spinning up a MAS is split into the prepare & start stage: prepare mas -> homeserver -> start mas to disentangle this. + masPrepare: async ({ context }, use) => { + const mas = new MatrixAuthenticationService(context); + await mas.prepare(); + await use(mas); + }, + mas: [ + async ({ masPrepare: mas, homeserver, mailhog }, use, testInfo) => { + await mas.start(homeserver, mailhog.instance); + await use(mas); + await mas.stop(testInfo); + }, + { auto: true }, + ], + startHomeserverOpts: async ({ masPrepare }, use) => { + await use({ + template: "mas-oidc", + variables: { + MAS_PORT: masPrepare.port, + }, + }); + }, + config: async ({ homeserver, startHomeserverOpts, context }, use) => { + const issuer = `http://localhost:${(startHomeserverOpts as StartHomeserverOpts).variables["MAS_PORT"]}/`; + const wellKnown = { + "m.homeserver": { + base_url: homeserver.config.baseUrl, + }, + "org.matrix.msc2965.authentication": { + issuer, + account: `${issuer}account`, + }, + }; + + // Ensure org.matrix.msc2965.authentication is in well-known + await context.route("https://localhost/.well-known/matrix/client", async (route) => { + await route.fulfill({ json: wellKnown }); + }); + + await use({ + default_server_config: wellKnown, + }); + }, +}); + +export { expect }; + +export async function registerAccountMas( + page: Page, + mailhog: API, + username: string, + email: string, + password: string, +): Promise { + await expect(page.getByText("Please sign in to continue:")).toBeVisible(); + + await page.getByRole("link", { name: "Create Account" }).click(); + await page.getByRole("textbox", { name: "Username" }).fill(username); + await page.getByRole("textbox", { name: "Email address" }).fill(email); + await page.getByRole("textbox", { name: "Password", exact: true }).fill(password); + await page.getByRole("textbox", { name: "Confirm Password" }).fill(password); + await page.getByRole("button", { name: "Continue" }).click(); + + let messages: Messages; + await expect(async () => { + messages = await mailhog.messages(); + expect(messages.items).toHaveLength(1); + }).toPass(); + expect(messages.items[0].to).toEqual(`${username} <${email}>`); + const [code] = messages.items[0].text.match(/(\d{6})/); + + await page.getByRole("textbox", { name: "6-digit code" }).fill(code); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("Allow access to your account?")).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); +} diff --git a/playwright/e2e/oidc/oidc-aware.spec.ts b/playwright/e2e/oidc/oidc-aware.spec.ts new file mode 100644 index 00000000000..2df450243ac --- /dev/null +++ b/playwright/e2e/oidc/oidc-aware.spec.ts @@ -0,0 +1,42 @@ +/* +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, registerAccountMas } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; + +test.describe("OIDC Aware", () => { + test.skip(isDendrite, "does not yet support MAS"); + test.slow(); // trace recording takes a while here + + test("can register an account and manage it", async ({ context, page, homeserver, mailhog, app }) => { + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!"); + + // Eventually, we should end up at the home screen. + await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); + await expect(page.getByRole("heading", { name: "Welcome alice", exact: true })).toBeVisible(); + + // Open settings and navigate to account management + await app.settings.openUserSettings("General"); + const newPagePromise = context.waitForEvent("page"); + await page.getByRole("button", { name: "Manage account" }).click(); + + // Assert new tab opened + const newPage = await newPagePromise; + await expect(newPage.getByText("Primary email")).toBeVisible(); + }); +}); diff --git a/playwright/e2e/oidc/oidc-native.spec.ts b/playwright/e2e/oidc/oidc-native.spec.ts new file mode 100644 index 00000000000..61795a85e56 --- /dev/null +++ b/playwright/e2e/oidc/oidc-native.spec.ts @@ -0,0 +1,74 @@ +/* +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, registerAccountMas } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; + +test.describe("OIDC Native", () => { + test.skip(isDendrite, "does not yet support MAS"); + test.slow(); // trace recording takes a while here + + test.use({ + labsFlags: ["feature_oidc_native_flow"], + }); + + test("can register the oauth2 client and an account", async ({ context, page, homeserver, mailhog, app, mas }) => { + const tokenUri = `http://localhost:${mas.port}/oauth2/token`; + const tokenApiPromise = page.waitForRequest( + (request) => request.url() === tokenUri && request.postDataJSON()["grant_type"] === "authorization_code", + ); + + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!"); + + // Eventually, we should end up at the home screen. + await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); + await expect(page.getByRole("heading", { name: "Welcome alice", exact: true })).toBeVisible(); + + const tokenApiRequest = await tokenApiPromise; + expect(tokenApiRequest.postDataJSON()["grant_type"]).toBe("authorization_code"); + + const deviceId = await page.evaluate(() => window.localStorage.mx_device_id); + + await app.settings.openUserSettings("General"); + const newPagePromise = context.waitForEvent("page"); + await page.getByRole("button", { name: "Manage account" }).click(); + await app.settings.closeDialog(); + + // Assert MAS sees the session as OIDC Native + const newPage = await newPagePromise; + await newPage.getByText("Sessions").click(); + await newPage.getByText(deviceId).click(); + await expect(newPage.getByText("Element")).toBeVisible(); + await expect(newPage.getByText("oauth2_session:")).toBeVisible(); + await expect(newPage.getByText("http://localhost:8080/")).toBeVisible(); + await newPage.close(); + + // Assert logging out revokes both tokens + const revokeUri = `http://localhost:${mas.port}/oauth2/revoke`; + const revokeAccessTokenPromise = page.waitForRequest( + (request) => request.url() === revokeUri && request.postDataJSON()["token_type_hint"] === "access_token", + ); + const revokeRefreshTokenPromise = page.waitForRequest( + (request) => request.url() === revokeUri && request.postDataJSON()["token_type_hint"] === "refresh_token", + ); + const locator = await app.settings.openUserMenu(); + await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click(); + await revokeAccessTokenPromise; + await revokeRefreshTokenPromise; + }); +}); diff --git a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts new file mode 100644 index 00000000000..287ac77cd41 --- /dev/null +++ b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts @@ -0,0 +1,54 @@ +/* +Copyright 2023 Ahmad Kadri +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 { test as base, expect } from "../../element-web-test"; +import { Credentials } from "../../plugins/homeserver"; + +const test = base.extend<{ + user2?: Credentials; +}>({}); + +test.describe("1:1 chat room", () => { + test.use({ + displayName: "Jeff", + user2: async ({ homeserver }, use) => { + const credentials = await homeserver.registerUser("user1234", "p4s5W0rD", "Timmy"); + await use(credentials); + }, + }); + + test.beforeEach(async ({ page, user2, user }) => { + await page.goto(`/#/user/${user2.userId}?action=chat`); + }); + + test("should open new 1:1 chat room after leaving the old one", async ({ page, user2 }) => { + // leave 1:1 chat room + await page.locator(".mx_LegacyRoomHeader_nametext").getByText(user2.displayName).click(); + await page.getByRole("menuitem", { name: "Leave" }).click(); + await page.getByRole("button", { name: "Leave" }).click(); + + // wait till the room was left + await expect( + page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomTile").getByText(user2.displayName), + ).not.toBeVisible(); + await page.waitForTimeout(500); // avoid race condition with routing + + // open new 1:1 chat room + await page.goto(`/#/user/${user2.userId}?action=chat`); + await expect(page.locator(".mx_LegacyRoomHeader_nametext").getByText(user2.displayName)).toBeVisible(); + }); +}); diff --git a/playwright/e2e/permalinks/permalinks.spec.ts b/playwright/e2e/permalinks/permalinks.spec.ts new file mode 100644 index 00000000000..6b3a10a4d66 --- /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 00000000000..458bb544c7c --- /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 00000000000..c4a8ae1bbe5 --- /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 00000000000..861181ba56a --- /dev/null +++ b/playwright/e2e/presence/presence.spec.ts @@ -0,0 +1,68 @@ +/* +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", () => { + // This is failing on CI (https://github.com/element-hq/element-web/issues/27270) + // but not locally, so debugging this is going to be tricky. Let's disable it for now. + test.skip("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/editing-messages.spec.ts b/playwright/e2e/read-receipts/editing-messages.spec.ts new file mode 100644 index 00000000000..5005ad62bfb --- /dev/null +++ b/playwright/e2e/read-receipts/editing-messages.spec.ts @@ -0,0 +1,504 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("editing messages", () => { + test.describe("in the main timeline", () => { + test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given I am not looking at the room + await util.goTo(room1); + + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When an edit appears in the room + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then it remains read + await util.assertStillRead(room2); + }); + test("Reading an edit leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given an edit is making the room unread + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + await util.assertStillRead(room2); + + // When I read it + await util.goTo(room2); + + // Then the room stays read + await util.assertStillRead(room2); + await util.goTo(room1); + await util.assertStillRead(room2); + }); + test("Editing a message after marking as read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given the room is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When a message is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then the room remains read + await util.assertStillRead(room2); + }); + test("Editing a reply after reading it makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given the room is all read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When a message is edited + await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); + + // Then it remains read + await util.assertStillRead(room2); + }); + test("Editing a reply after marking as read makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a reply is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); + await util.assertUnread(room2, 2); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When the reply is edited + await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); + + // Then the room remains read + await util.assertStillRead(room2); + }); + test("A room with an edit is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When an edit appears in the room + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then it remains read + await util.assertStillRead(room2); + + // And remains so after a reload + await util.saveAndReload(); + await util.assertStillRead(room2); + }); + test("An edited message becomes read if it happens while I am looking", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message is marked as read + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertRead(room2); + + // When I see an edit appear in the room I am looking at + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then it becomes read + await util.assertStillRead(room2); + }); + test("A room where all edits are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message was edited and read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.editOf("Msg1", "Msg1 Edit1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + + // When I reload + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + }); + }); + + test.describe("in threads", () => { + test("An edit of a threaded message makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given we have read the thread + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Resp1"); + await util.goTo(room1); + + // When a message inside it is edited + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + + // Then the room and thread are read + await util.assertStillRead(room2); + await util.goTo(room2); + await util.assertReadThread("Msg1"); + }); + + test("Reading an edit of a threaded message makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an edited thread message appears after we read it + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Resp1"); + await util.goTo(room1); + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + await util.assertStillRead(room2); + + // When I read it + await util.goTo(room2); + await util.openThread("Msg1"); + + // Then the room and thread are still read + await util.assertStillRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Marking a room as read after an edit in a thread makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an edit in a thread is making the room unread + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.editOf("Resp1", "Edit1"), + ]); + await util.assertUnread(room2, 1); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then it is read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Editing a thread message after marking as read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a room is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When a message is edited + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + + // Then the room remains read + await util.assertStillRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("A room with an edited threaded message is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an edit in a thread is leaving a room read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.markAsRead(room2); + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + await util.assertStillRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then is it still read + await util.assertRead(room2); + }); + + test("A room where all threaded edits are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + await util.assertUnread(room2, 1); + + await util.goTo(room2); + + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + await util.goTo(room1); // Make sure we are looking at room1 after reload + await util.assertStillRead(room2); + + await util.saveAndReload(); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("A room where all threaded edits are marked as read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.editOf("Resp1", "Edit1"), + ]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When I restart + await util.saveAndReload(); + + // It is still read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + }); + + test.describe("thread roots", () => { + test("An edit of a thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read a thread + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.backToThreadsList(); + await util.assertRead(room2); + await util.goTo(room1); + + // When the thread root is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Edit1")]); + + // Then the room is read + await util.assertStillRead(room2); + + // And the thread is read + await util.goTo(room2); + await util.assertStillRead(room2); + await util.assertReadThread("Edit1"); + }); + + test("Reading an edit of a thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a fully-read thread exists + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + await util.assertRead(room2); + + // When the thread root is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // And I read that edit + await util.goTo(room2); + + // Then the room becomes read and stays read + await util.assertStillRead(room2); + await util.goTo(room1); + await util.assertStillRead(room2); + }); + + test("Editing a thread root after reading leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a fully-read thread exists + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + + // When the thread root is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then the room stays read + await util.assertStillRead(room2); + }); + + test("Marking a room as read after an edit of a thread root keeps it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a fully-read thread exists + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + await util.assertRead(room2); + + // When the thread root is edited (and I receive another message + // to allow Mark as read) + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1"), "Msg2"]); + + // And when I mark the room as read + await util.markAsRead(room2); + + // Then the room becomes read and stays read + await util.assertStillRead(room2); + await util.goTo(room1); + await util.assertStillRead(room2); + }); + + test("Editing a thread root that is a reply after marking as read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread based on a reply exists and is read because it is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg", + msg.replyTo("Msg", "Reply"), + msg.threadedOff("Reply", "InThread"), + ]); + await util.assertUnread(room2, 2); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I edit the thread root + await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); + + // Then the room is read + await util.assertStillRead(room2); + + // And the thread is read + await util.goTo(room2); + await util.assertReadThread("Edited Reply"); + }); + + test("Marking a room as read after an edit of a thread root that is a reply leaves it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread based on a reply exists and the reply has been edited + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg", + msg.replyTo("Msg", "Reply"), + msg.threadedOff("Reply", "InThread"), + ]); + await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); + await util.assertUnread(room2, 2); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then the room and thread are read + await util.assertStillRead(room2); + await util.goTo(room2); + await util.assertReadThread("Edited Reply"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/high-level.spec.ts b/playwright/e2e/read-receipts/high-level.spec.ts new file mode 100644 index 00000000000..e237afd64a8 --- /dev/null +++ b/playwright/e2e/read-receipts/high-level.spec.ts @@ -0,0 +1,494 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { customEvent, many, test } from "."; + +test.describe("Read receipts", () => { + test.describe("Message ordering", () => { + test.describe("in the main timeline", () => { + test.fixme( + "A receipt for the last event in sync order (even with wrong ts) marks a room as read", + () => {}, + ); + test.fixme( + "A receipt for a non-last event in sync order (even when ts makes it last) leaves room unread", + () => {}, + ); + }); + + test.describe("in threads", () => { + // These don't pass yet - we need MSC4033 - we don't even know the Sync order yet + test.fixme( + "A receipt for the last event in sync order (even with wrong ts) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for a non-last event in sync order (even when ts makes it last) leaves thread unread", + () => {}, + ); + + // These pass now and should not later - we should use order from MSC4033 instead of ts + // These are broken out + test.fixme( + "A receipt for last threaded event in ts order (even when it was received non-last) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for non-last threaded event in ts order (even when it was received last) leaves thread unread", + () => {}, + ); + test.fixme( + "A receipt for last threaded edit in ts order (even when it was received non-last) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for non-last threaded edit in ts order (even when it was received last) leaves thread unread", + () => {}, + ); + test.fixme( + "A receipt for last threaded reaction in ts order (even when it was received non-last) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for non-last threaded reaction in ts order (even when it was received last) leaves thread unread", + () => {}, + ); + }); + + test.describe("thread roots", () => { + test.fixme( + "A receipt for last reaction to thread root in sync order (even when ts makes it last) marks room as read", + () => {}, + ); + test.fixme( + "A receipt for non-last reaction to thread root in sync order (even when ts makes it last) leaves room unread", + () => {}, + ); + test.fixme( + "A receipt for last edit to thread root in sync order (even when ts makes it last) marks room as read", + () => {}, + ); + test.fixme( + "A receipt for non-last edit to thread root in sync order (even when ts makes it last) leaves room unread", + () => {}, + ); + }); + }); + + test.describe("Ignored events", () => { + test("If all events after receipt are unimportant, the room is read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.receiveMessages(room2, [customEvent("org.custom.event", { body: "foobar" })]); + await util.assertRead(room2); + }); + test("Sending an important event after unimportant ones makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + }) => { + // Given We have read the important messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When we receive unimportant messages + await util.receiveMessages(room2, [customEvent("org.custom.event", { body: "foobar" })]); + + // Then the room is still read + await util.assertStillRead(room2); + + // And when we receive more important ones + await util.receiveMessages(room2, ["Hello"]); + + // The room is unread again + await util.assertUnread(room2, 1); + }); + test("A receipt for the last unimportant event makes the room read, even if all are unimportant", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + }) => { + // Display room 1 + await util.goTo(room1); + + // The room 2 is read + await util.assertRead(room2); + + // We received 3 unimportant messages to room2 + await util.receiveMessages(room2, [ + customEvent("org.custom.event", { body: "foobar1" }), + customEvent("org.custom.event", { body: "foobar2" }), + customEvent("org.custom.event", { body: "foobar3" }), + ]); + + // The room 2 is still read + await util.assertStillRead(room2); + }); + }); + + test.describe("Paging up", () => { + test("Paging up through old messages after a room is read leaves the room read", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + }) => { + // Given lots of messages are in the room, but we have read them + await util.goTo(room1); + await util.receiveMessages(room2, many("Msg", 110)); + await util.assertUnread(room2, 110); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When we restart, so only recent messages are loaded + await util.saveAndReload(); + await util.goTo(room2); + await util.assertMessageNotLoaded("Msg0010"); + + // And we page up, loading in old messages + await util.pageUp(); + await page.waitForTimeout(200); + await util.pageUp(); + await page.waitForTimeout(200); + await util.pageUp(); + await util.assertMessageLoaded("Msg0010"); + + // Then the room remains read + await util.assertStillRead(room2); + }); + test("Paging up through old messages of an unread room leaves the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given lots of messages are in the room, and they are not read + await util.goTo(room1); + await util.receiveMessages(room2, many("x\ny\nz\nMsg", 40)); // newline to spread out messages + await util.assertUnread(room2, 40); + + // When I jump to a message in the middle and page up + await msg.jumpTo(room2.name, "x\ny\nz\nMsg0020"); + await util.pageUp(); + + // Then the room is still unread + await util.assertUnreadGreaterThan(room2, 1); + }); + test("Paging up to find old threads that were previously read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + test.slow(); + + // Given lots of messages in threads are all read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root1", + "Root2", + "Root3", + ...msg.manyThreadedOff("Root1", many("T", 20)), + ...msg.manyThreadedOff("Root2", many("T", 20)), + ...msg.manyThreadedOff("Root3", many("T", 20)), + ]); + await util.goTo(room2); + await util.assertRead(room2); + await util.assertUnreadThread("Root1"); + await util.assertUnreadThread("Root2"); + await util.assertUnreadThread("Root3"); + await util.openThread("Root1"); + await util.assertReadThread("Root1"); + await util.openThread("Root2"); + await util.assertReadThread("Root2"); + await util.openThread("Root3"); + await util.assertReadThread("Root3"); + + // When I restart and page up to load old thread roots + await util.goTo(room1); + await util.saveAndReload(); + await util.goTo(room2); + await util.pageUp(); + + // Then the room and threads remain read + await util.assertRead(room2); + await util.assertReadThread("Root1"); + await util.assertReadThread("Root2"); + await util.assertReadThread("Root3"); + }); + + test("Paging up to find old threads that were never read keeps the room unread", async ({ + cryptoBackend, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + test.slow(); + + // Given lots of messages in threads that are unread + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root1", + "Root2", + "Root3", + ...msg.manyThreadedOff("Root1", many("T", 2)), + ...msg.manyThreadedOff("Root2", many("T", 2)), + ...msg.manyThreadedOff("Root3", many("T", 2)), + ...many("Msg", 100), + ]); + await util.goTo(room2); + await util.assertRead(room2); + await util.assertUnreadThread("Root1"); + await util.assertUnreadThread("Root2"); + await util.assertUnreadThread("Root3"); + + // When I restart + await util.closeThreadsPanel(); + await util.goTo(room1); + await util.saveAndReload(); + + // Then the room remembers it's read + // TODO: I (andyb) think this will fall in an encrypted room + await util.assertRead(room2); + + // And when I page up to load old thread roots + await util.goTo(room2); + await util.pageUp(); + + // Then the room remains read + await util.assertRead(room2); + await util.assertUnreadThread("Root1"); + await util.assertUnreadThread("Root2"); + await util.assertUnreadThread("Root3"); + }); + + test("Looking in thread view to find old threads that were never read makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given lots of messages in threads that are unread + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root1", + "Root2", + "Root3", + ...msg.manyThreadedOff("Root1", many("T", 2)), + ...msg.manyThreadedOff("Root2", many("T", 2)), + ...msg.manyThreadedOff("Root3", many("T", 2)), + ...many("Msg", 100), + ]); + await util.goTo(room2); + await util.assertRead(room2); + await util.assertUnreadThread("Root1"); + await util.assertUnreadThread("Root2"); + await util.assertUnreadThread("Root3"); + + // When I restart + await util.closeThreadsPanel(); + await util.goTo(room1); + await util.saveAndReload(); + + // Then the room remembers it's read + // TODO: I (andyb) think this will fall in an encrypted room + await util.assertRead(room2); + + // And when I open the threads view + await util.goTo(room2); + await util.openThreadList(); + + // Then the room remains read + await util.assertRead(room2); + await util.assertUnreadThread("Root1"); + await util.assertUnreadThread("Root2"); + await util.assertUnreadThread("Root3"); + }); + + test("After marking room as read, paging up to find old threads that were never read leaves the room read", async ({ + cryptoBackend, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + test.slow(); + + // Given lots of messages in threads that are unread but I marked as read on a main timeline message + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root1", + "Root2", + "Root3", + ...msg.manyThreadedOff("Root1", many("T", 2)), + ...msg.manyThreadedOff("Root2", many("T", 2)), + ...msg.manyThreadedOff("Root3", many("T", 2)), + ...many("Msg", 100), + ]); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then the room remembers it's read + await util.assertRead(room2); + + // And when I page up to load old thread roots + await util.goTo(room2); + await util.pageUp(); + await util.pageUp(); + await util.pageUp(); + + // Then the room remains read + await util.assertStillRead(room2); + await util.assertReadThread("Root1"); + await util.assertReadThread("Root2"); + await util.assertReadThread("Root3"); + }); + 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, + msg, + }) => { + // Given lots of messages in threads that are unread but I marked as read on a thread message + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root1", + "Root2", + "Root3", + ...msg.manyThreadedOff("Root1", many("T1-", 2)), + ...msg.manyThreadedOff("Root2", many("T2-", 2)), + ...msg.manyThreadedOff("Root3", many("T3-", 2)), + ...many("Msg", 100), + msg.threadedOff("Msg0099", "Thread off 99"), + ]); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then the room remembers it's read + await util.assertRead(room2); + + // And when I page up to load old thread roots + await util.goTo(room2); + await util.openThreadList(); + + // Then the room remains read + await util.assertStillRead(room2); + await util.assertReadThread("Root1"); + await util.assertReadThread("Root2"); + await util.assertReadThread("Root3"); + }); + }); + + test.describe("Room list order", () => { + 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", () => { + test.describe("in the main timeline", () => { + test.fixme("A new message that mentions me shows a notification", () => {}); + test.fixme( + "Reading a notifying message reduces the notification count in the room list, space and tab", + () => {}, + ); + test.fixme( + "Reading the last notifying message removes the notification marker from room list, space and tab", + () => {}, + ); + test.fixme("Editing a message to mentions me shows a notification", () => {}); + test.fixme("Reading the last notifying edited message removes the notification marker", () => {}); + test.fixme("Redacting a notifying message removes the notification marker", () => {}); + }); + + test.describe("in threads", () => { + test.fixme("A new threaded message that mentions me shows a notification", () => {}); + test.fixme("Reading a notifying threaded message removes the notification count", () => {}); + test.fixme( + "Notification count remains steady when reading threads that contain seen notifications", + () => {}, + ); + test.fixme( + "Notification count remains steady when paging up thread view even when threads contain seen notifications", + () => {}, + ); + test.fixme( + "Notification count remains steady when paging up thread view after mark as unread even if older threads contain notifications", + () => {}, + ); + test.fixme("Redacting a notifying threaded message removes the notification marker", () => {}); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/index.ts b/playwright/e2e/read-receipts/index.ts new file mode 100644 index 00000000000..4dd0450fb9c --- /dev/null +++ b/playwright/e2e/read-receipts/index.ts @@ -0,0 +1,658 @@ +/* +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 { JSHandle, Page } from "@playwright/test"; +import type { MatrixEvent, Room, IndexedDBStore, ReceiptType } from "matrix-js-sdk/src/matrix"; +import { test as base, expect } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; +import { Client } from "../../pages/client"; +import { ElementAppPage } from "../../pages/ElementAppPage"; + +/** + * Set up for a read receipt test: + * - Create a user with the supplied name + * - As that user, create two rooms with the supplied names + * - Create a bot with the supplied name + * - Invite the bot to both rooms and ensure that it has joined + */ +export const test = base.extend<{ + roomAlphaName?: string; + roomAlpha: { name: string; roomId: string }; + roomBetaName?: string; + roomBeta: { name: string; roomId: string }; + msg: MessageBuilder; + util: Helpers; +}>({ + displayName: "Mae", + botCreateOpts: { displayName: "Other User" }, + + roomAlphaName: "Room Alpha", + roomAlpha: async ({ roomAlphaName: name, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await use({ name, roomId }); + }, + roomBetaName: "Room Beta", + roomBeta: async ({ roomBetaName: name, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await use({ name, roomId }); + }, + msg: async ({ page, app, util }, use) => { + await use(new MessageBuilder(page, app, util)); + }, + util: async ({ roomAlpha, roomBeta, page, app, bot }, use) => { + await use(new Helpers(page, app, bot)); + }, +}); + +/** + * A utility that is able to find messages based on their content, by looking + * inside the `timeline` objects in the object model. + * + * Crucially, we hold on to references to events that have been edited or + * redacted, so we can still look them up by their old content. + * + * Provides utilities that build on the ability to find messages, e.g. replyTo, + * which finds a message and then constructs a reply to it. + */ +export class MessageBuilder { + constructor( + private page: Page, + private app: ElementAppPage, + private helpers: Helpers, + ) {} + + /** + * Map of message content -> event. + */ + messages = new Map>>(); + + /** + * Utility to find a MatrixEvent by its body content + * @param room - the room to search for the event in + * @param message - the body of the event to search for + * @param includeThreads - whether to search within threads too + */ + async getMessage(room: JSHandle, message: string, includeThreads = false): Promise> { + const cached = this.messages.get(message); + if (cached) { + return cached; + } + + const promise = room.evaluateHandle( + async (room, { message, includeThreads }) => { + let ev = room.timeline.find((e) => e.getContent().body === message); + if (!ev && includeThreads) { + for (const thread of room.getThreads()) { + ev = thread.timeline.find((e) => e.getContent().body === message); + if (ev) break; + } + } + + if (ev) return ev; + + return new Promise((resolve) => { + room.on("Room.timeline" as any, (ev: MatrixEvent) => { + if (ev.getContent().body === message) { + resolve(ev); + } + }); + }); + }, + { message, includeThreads }, + ); + + this.messages.set(message, promise); + return promise; + } + + /** + * MessageContentSpec to send an edit into a room + * @param originalMessage - the body of the message to edit + * @param newMessage - the message body to send in the edit + */ + editOf(originalMessage: string, newMessage: string): MessageContentSpec { + return new (class extends MessageContentSpec { + public async getContent(room: JSHandle): Promise> { + const ev = await this.messageFinder.getMessage(room, originalMessage, true); + + return ev.evaluate((ev, newMessage) => { + // If this event has been redacted, its msgtype will be + // undefined. In that case, we guess msgtype as m.text. + const msgtype = ev.getContent().msgtype ?? "m.text"; + return { + "msgtype": msgtype, + "body": `* ${newMessage}`, + "m.new_content": { + msgtype: msgtype, + body: newMessage, + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: ev.getId(), + }, + }; + }, newMessage); + } + })(this); + } + + /** + * MessageContentSpec to send a reply into a room + * @param targetMessage - the body of the message to reply to + * @param newMessage - the message body to send into the reply + */ + replyTo(targetMessage: string, newMessage: string): MessageContentSpec { + return new (class extends MessageContentSpec { + 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(), + }, + }, + }; + }, newMessage); + } + })(this); + } + + /** + * MessageContentSpec to send a threaded response into a room + * @param rootMessage - the body of the thread root message to send a response to + * @param newMessage - the message body to send into the thread response + */ + threadedOff(rootMessage: string, newMessage: string): MessageContentSpec { + return new (class extends MessageContentSpec { + public async getContent(room: JSHandle): Promise> { + const ev = await this.messageFinder.getMessage(room, rootMessage); + return ev.evaluate((ev, newMessage) => { + return { + "msgtype": "m.text", + "body": newMessage, + "m.relates_to": { + event_id: ev.getId(), + is_falling_back: true, + rel_type: "m.thread", + }, + }; + }, newMessage); + } + })(this); + } + + /** + * Generate MessageContentSpecs to send multiple threaded responses into a room. + * + * @param rootMessage - the body of the thread root message to send a response to + * @param newMessages - the contents of the messages + */ + manyThreadedOff(rootMessage: string, newMessages: Array): Array { + return newMessages.map((body) => this.threadedOff(rootMessage, body)); + } + + /** + * BotActionSpec to send a reaction to an existing event into a room + * @param targetMessage - the body of the message to send a reaction to + * @param reaction - the key of the reaction to send into the room + */ + reactionTo(targetMessage: string, reaction: string): BotActionSpec { + return new (class extends BotActionSpec { + public async performAction(bot: Bot, room: JSHandle): Promise { + const ev = await this.messageFinder.getMessage(room, targetMessage, true); + const { id, threadId } = await ev.evaluate((ev) => ({ + id: ev.getId(), + threadId: !ev.isThreadRoot ? ev.threadRootId : undefined, + })); + const roomId = await room.evaluate((room) => room.roomId); + + await bot.sendEvent(roomId, threadId ?? null, "m.reaction", { + "m.relates_to": { + rel_type: "m.annotation", + event_id: id, + key: reaction, + }, + }); + } + })(this); + } + + /** + * BotActionSpec to send a redaction into a room + * @param targetMessage - the body of the message to send a redaction to + */ + redactionOf(targetMessage: string): BotActionSpec { + return new (class extends BotActionSpec { + public async performAction(bot: Bot, room: JSHandle): Promise { + const ev = await this.messageFinder.getMessage(room, targetMessage, true); + const { id, threadId } = await ev.evaluate((ev) => ({ + id: ev.getId(), + threadId: !ev.isThreadRoot ? ev.threadRootId : undefined, + })); + const roomId = await room.evaluate((room) => room.roomId); + await bot.redactEvent(roomId, threadId, id); + } + })(this); + } + + /** + * Find and display a message. + * + * @param roomName the name of the room to look inside + * @param message the content of the message to fine + * @param includeThreads look for messages inside threads, not just the main timeline + */ + async jumpTo(roomName: string, message: string, includeThreads = false) { + const room = await this.helpers.findRoomByName(roomName); + const foundMessage = await this.getMessage(room, message, includeThreads); + const roomId = await room.evaluate((room) => room.roomId); + const foundMessageId = await foundMessage.evaluate((ev) => ev.getId()); + await this.page.goto(`/#/room/${roomId}/${foundMessageId}`); + } + + async sendThreadedReadReceipt(room: JSHandle, targetMessage: string) { + const event = await this.getMessage(room, targetMessage, true); + + await this.app.client.evaluate( + (client, { event }) => { + return client.sendReadReceipt(event); + }, + { event }, + ); + } + + async sendUnthreadedReadReceipt(room: JSHandle, targetMessage: string) { + const event = await this.getMessage(room, targetMessage, true); + + await this.app.client.evaluate( + (client, { event }) => { + return client.sendReadReceipt(event, "m.read" as any as ReceiptType, true); + }, + { event }, + ); + } +} + +/** + * Something that can provide the content of a message. + * + * For example, we return and instance of this from {@link + * MessageBuilder.replyTo} which creates a reply based on a previous message. + */ +export abstract class MessageContentSpec { + messageFinder: MessageBuilder | null; + + constructor(messageFinder: MessageBuilder = null) { + this.messageFinder = messageFinder; + } + + public abstract getContent(room: JSHandle): Promise>; +} + +/** + * Something that can perform an action at the time we would usually send a + * message. + * + * For example, we return an instance of this from {@link + * MessageBuilder.redactionOf} which redacts the message we are referring to. + */ +export abstract class BotActionSpec { + messageFinder: MessageBuilder | null; + + constructor(messageFinder: MessageBuilder = null) { + this.messageFinder = messageFinder; + } + + public abstract performAction(client: Client, room: JSHandle): Promise; +} + +/** + * Something that we will turn into a message or event when we pass it in to + * e.g. receiveMessages. + */ +export type Message = string | MessageContentSpec | BotActionSpec; + +class Helpers { + constructor( + private page: Page, + private app: ElementAppPage, + private bot: Bot, + ) {} + + /** + * Use the supplied client to send messages or perform actions as specified by + * the supplied {@link Message} items. + */ + async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: Message[]) { + const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name); + const roomId = await room.evaluate((room) => room.roomId); + + for (const message of messages) { + if (typeof message === "string") { + await cli.sendMessage(roomId, { body: message, msgtype: "m.text" }); + } else if (message instanceof MessageContentSpec) { + await cli.sendMessage(roomId, await message.getContent(room)); + } else { + await message.performAction(cli, room); + } + // TODO: without this wait, some tests that send lots of messages flake + // from time to time. I (andyb) have done some investigation, but it + // needs more work to figure out. The messages do arrive over sync, but + // they never appear in the timeline, and they never fire a + // Room.timeline event. I think this only happens with events that refer + // to other events (e.g. replies), so it might be caused by the + // referring event arriving before the referred-to event. + await this.page.waitForTimeout(100); + } + } + + /** + * Open the room with the supplied name. + */ + async goTo(room: string | { name: string }) { + 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. + */ + async openThread(rootMessage: string) { + const tile = this.page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: rootMessage }); + await tile.hover(); + await tile.getByRole("button", { name: "Reply in thread" }).click(); + await expect(this.page.locator(".mx_ThreadView_timelinePanelWrapper")).toBeVisible(); + } + + /** + * Close the threads panel. (Actually, close any right panel, but for these + * tests we only open the threads panel.) + */ + async closeThreadsPanel() { + await this.page.locator(".mx_RightPanel").getByLabel("Close").click(); + await expect(this.page.locator(".mx_RightPanel")).not.toBeVisible(); + } + + /** + * Return to the list of threads, given we are viewing a single thread. + */ + async backToThreadsList() { + await this.page.locator(".mx_RightPanel").getByLabel("Threads").click(); + } + + /** + * Assert that the message containing the supplied text is visible in the UI. + * Note: matches part of the message content as well as the whole of it. + */ + async assertMessageLoaded(messagePart: string) { + await expect(this.page.locator(".mx_EventTile_body").getByText(messagePart)).toBeVisible(); + } + + /** + * Assert that the message containing the supplied text is not visible in the UI. + * Note: matches part of the message content as well as the whole of it. + */ + async assertMessageNotLoaded(messagePart: string) { + await expect(this.page.locator(".mx_EventTile_body").getByText(messagePart)).not.toBeVisible(); + } + + /** + * Scroll the messages panel up 1000 pixels. + */ + async pageUp() { + await this.page.locator(".mx_RoomView_messagePanel").evaluateAll((messagePanels) => { + messagePanels.forEach((messagePanel) => (messagePanel.scrollTop -= 1000)); + }); + } + + getRoomListTile(room: string | { name: string }) { + const roomName = typeof room === "string" ? room : room.name; + return this.page.getByRole("treeitem", { name: new RegExp("^" + roomName) }); + } + + /** + * Click the "Mark as Read" context menu item on the room with the supplied name + * in the room list. + */ + async markAsRead(room: string | { name: string }) { + await this.getRoomListTile(room).click({ button: "right" }); + await this.page.getByText("Mark as read").click(); + } + + /** + * Assert that the room with the supplied name is "read" in the room list - i.g. + * has not dot or count of unread messages. + */ + async assertRead(room: string | { name: string }) { + const tile = this.getRoomListTile(room); + await expect(tile.locator(".mx_NotificationBadge_dot")).not.toBeVisible(); + await expect(tile.locator(".mx_NotificationBadge_count")).not.toBeVisible(); + } + + /** + * Assert that this room remains read, when it was previously read. + * (In practice, this just waits a short while to allow any unread marker to + * appear, and then asserts that the room is read.) + */ + async assertStillRead(room: string | { name: string }) { + await this.page.waitForTimeout(200); + await this.assertRead(room); + } + + /** + * Assert a given room is marked as unread (via the room list tile) + * @param room - the name of the room to check + * @param count - the numeric count to assert, or if "." specified then a bold/dot (no count) state is asserted + */ + async assertUnread(room: string | { name: string }, count: number | ".") { + const tile = this.getRoomListTile(room); + if (count === ".") { + await expect(tile.locator(".mx_NotificationBadge_dot")).toBeVisible(); + } else { + await expect(tile.locator(".mx_NotificationBadge_count")).toHaveText(count.toString()); + } + } + + /** + * Assert a given room is marked as unread, and the number of unread + * messages is less than the supplied count. + * + * @param room - the name of the room to check + * @param lessThan - the number of unread messages that is too many + */ + async assertUnreadLessThan(room: string | { name: string }, lessThan: number) { + const tile = this.getRoomListTile(room); + // https://playwright.dev/docs/test-assertions#expectpoll + // .toBeLessThan doesn't have a retry mechanism, so we use .poll + await expect + .poll(async () => { + return parseInt(await tile.locator(".mx_NotificationBadge_count").textContent(), 10); + }) + .toBeLessThan(lessThan); + } + + /** + * Assert a given room is marked as unread, and the number of unread + * messages is greater than the supplied count. + * + * @param room - the name of the room to check + * @param greaterThan - the number of unread messages that is too few + */ + async assertUnreadGreaterThan(room: string | { name: string }, greaterThan: number) { + const tile = this.getRoomListTile(room); + // https://playwright.dev/docs/test-assertions#expectpoll + // .toBeGreaterThan doesn't have a retry mechanism, so we use .poll + await expect + .poll(async () => { + return parseInt(await tile.locator(".mx_NotificationBadge_count").textContent(), 10); + }) + .toBeGreaterThan(greaterThan); + } + + /** + * Click the "Threads" or "Back" button if needed to get to the threads list. + */ + async openThreadList() { + // If we've just entered the room, the threads panel takes a while to decide + // whether it's open or not - wait here to give it a chance to settle. + await this.page.waitForTimeout(200); + + const ariaCurrent = await this.page.getByTestId("threadsButton").getAttribute("aria-current"); + if (ariaCurrent !== "true") { + await this.page.getByTestId("threadsButton").click(); + } + + const threadPanel = this.page.locator(".mx_ThreadPanel"); + await expect(threadPanel).toBeVisible(); + await threadPanel.evaluate(($panel) => { + const $button = $panel.querySelector('.mx_BaseCard_back[aria-label="Threads"]'); + // If the Threads back button is present then click it - the + // threads button can open either threads list or thread panel + if ($button) { + $button.click(); + } + }); + } + + async findRoomByName(roomName: string): Promise> { + return this.app.client.evaluateHandle((cli, roomName) => { + return cli.getRooms().find((r) => r.name === roomName); + }, roomName); + } + + private async getThreadListTile(rootMessage: string) { + await this.openThreadList(); + return this.page.locator(".mx_ThreadPanel li", { hasText: rootMessage }); + } + + /** + * Assert that the thread with the supplied content in its root message is shown + * as read in the Threads list. + */ + async assertReadThread(rootMessage: string) { + const tile = await this.getThreadListTile(rootMessage); + await expect(tile.locator(".mx_NotificationBadge")).not.toBeVisible(); + } + + /** + * Assert that the thread with the supplied content in its root message is shown + * as unread in the Threads list. + */ + async assertUnreadThread(rootMessage: string) { + const tile = await this.getThreadListTile(rootMessage); + await expect(tile.locator(".mx_NotificationBadge")).toBeVisible(); + } + + /** + * Save our indexeddb information and then refresh the page. + */ + async saveAndReload() { + await this.app.client.evaluate((cli) => { + // @ts-ignore + return (cli.store as IndexedDBStore).reallySave(); + }); + await this.page.reload(); + // Wait for the app to reload + await expect(this.page.locator(".mx_RoomView")).toBeVisible(); + } + + /** + * Sends messages into given room as a bot + * @param room - the name of the room to send messages into + * @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf` + */ + async receiveMessages(room: string | { name: string }, messages: Message[]) { + 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); + } + } +} + +/** + * BotActionSpec to send a custom event + * @param eventType - the type of the event to send + * @param content - the event content to send + */ +export function customEvent(eventType: string, content: Record): BotActionSpec { + return new (class extends BotActionSpec { + public async performAction(cli: Client, room: JSHandle): Promise { + const roomId = await room.evaluate((room) => room.roomId); + await cli.sendEvent(roomId, null, eventType, content); + } + })(); +} + +/** + * Generate strings with the supplied prefix, suffixed with numbers. + * + * @param prefix the prefix of each string + * @param howMany the number of strings to generate + */ +export function many(prefix: string, howMany: number): Array { + return Array.from(Array(howMany).keys()).map((i) => prefix + i.toString().padStart(4, "0")); +} + +export { expect }; diff --git a/playwright/e2e/read-receipts/missing-referents.spec.ts b/playwright/e2e/read-receipts/missing-referents.spec.ts new file mode 100644 index 00000000000..28313ee35b9 --- /dev/null +++ b/playwright/e2e/read-receipts/missing-referents.spec.ts @@ -0,0 +1,59 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("messages with missing referents", () => { + test.fixme( + "A message in an unknown thread is not visible and the room is read", + async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given a thread existed and the room is read + await util.goTo(room1); + await util.receiveMessages(room2, ["Root1", msg.threadedOff("Root1", "T1a")]); + + // When I restart, forgetting the thread root + // And I receive a message on that thread + // Then the message is invisible and the room remains read + }, + ); + test.fixme("When a message's thread root appears later the thread appears and the room is unread", () => {}); + test.fixme("An edit of an unknown message is not visible and the room is read", () => {}); + test.fixme("When an edit's message appears later the edited version appears and the room is unread", () => {}); + test.fixme("A reaction to an unknown message is not visible and the room is read", () => {}); + test.fixme("When an reactions's message appears later it appears and the room is unread", () => {}); + // Harder: validate that we request the messages we are missing? + }); + + test.describe("receipts with missing events", () => { + // Later: when we have order in receipts, we can change these tests to + // make receipts still work, even when their message is not found. + test.fixme("A receipt for an unknown message does not change the state of an unread room", () => {}); + test.fixme("A receipt for an unknown message does not change the state of a read room", () => {}); + test.fixme("A threaded receipt for an unknown message does not change the state of an unread thread", () => {}); + test.fixme("A threaded receipt for an unknown message does not change the state of a read thread", () => {}); + test.fixme("A threaded receipt for an unknown thread does not change the state of an unread thread", () => {}); + test.fixme("A threaded receipt for an unknown thread does not change the state of a read thread", () => {}); + test.fixme("A threaded receipt for a message on main does not change the state of an unread room", () => {}); + test.fixme("A threaded receipt for a message on main does not change the state of a read room", () => {}); + test.fixme("A main receipt for a message on a thread does not change the state of an unread room", () => {}); + test.fixme("A main receipt for a message on a thread does not change the state of a read room", () => {}); + test.fixme("A threaded receipt for a thread root does not mark it as read", () => {}); + // Harder: validate that we request the messages we are missing? + }); +}); diff --git a/playwright/e2e/read-receipts/new-messages.spec.ts b/playwright/e2e/read-receipts/new-messages.spec.ts new file mode 100644 index 00000000000..97308a4bb29 --- /dev/null +++ b/playwright/e2e/read-receipts/new-messages.spec.ts @@ -0,0 +1,549 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { many, test } from "."; + +test.describe("Read receipts", () => { + test.describe("new messages", () => { + test.describe("in the main timeline", () => { + test("Receiving a message makes a room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I am in a different room + await util.goTo(room1); + await util.assertRead(room2); + + // When I receive some messages + await util.receiveMessages(room2, ["Msg1"]); + + // Then the room is marked as unread + await util.assertUnread(room2, 1); + }); + test("Reading latest message makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have some unread messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + // When I read the main timeline + await util.goTo(room2); + + // Then the room becomes read + await util.assertRead(room2); + }); + test("Reading an older message leaves the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given there are lots of messages in a room + await util.goTo(room1); + await util.receiveMessages(room2, many("Msg", 30)); + await util.assertUnread(room2, 30); + + // When I jump to one of the older messages + await msg.jumpTo(room2.name, "Msg0001"); + + // Then the room is still unread, but some messages were read + await util.assertUnreadLessThan(room2, 30); + }); + test("Marking a room as read makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given I have some unread messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then it is read + await util.assertRead(room2); + }); + test("Receiving a new message after marking as read makes it unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have marked my messages as read + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I receive a new message + await util.receiveMessages(room2, ["Msg2"]); + + // Then the room is unread + await util.assertUnread(room2, 1); + }); + test("A room with a new message is still unread after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have an unread message + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + // When I restart + await util.saveAndReload(); + + // Then I still have an unread message + await util.assertUnread(room2, 1); + }); + test("A room where all messages are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read all messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then all messages are still read + await util.assertRead(room2); + }); + test("A room that was marked as read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have marked all messages as read + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then all messages are still read + await util.assertRead(room2); + }); + }); + + test.describe("in threads", () => { + test("Receiving a message makes a room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message arrived and is read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When I receive a threaded message + await util.receiveMessages(room2, [msg.threadedOff("Msg1", "Resp1")]); + + // Then the room stays read + await util.assertRead(room2); + // but the thread is unread + await util.goTo(room2); + await util.assertUnreadThread("Msg1"); + }); + + test("Reading the last threaded message makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists and is not read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + + // When I read it + await util.openThread("Msg1"); + + // The thread becomes read + await util.assertReadThread("Msg1"); + }); + + test("Reading a thread message makes the thread read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.threadedOff("Msg1", "Resp2"), + ]); + await util.assertUnread(room2, 1); // (Sanity) + + // When I read the main timeline + await util.goTo(room2); + + // Then room is read + await util.assertRead(room2); + + // Reading the thread causes it to become read too + await util.openThread("Msg1"); + await util.assertReadThread("Msg1"); + await util.assertRead(room2); + }); + + test("Reading an older thread message leaves the thread unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given there are many messages in a thread + await util.goTo(room1); + await util.receiveMessages(room2, [ + "ThreadRoot", + ...msg.manyThreadedOff("ThreadRoot", many("InThread", 20)), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertUnreadThread("ThreadRoot"); + await util.goTo(room1); + + // When I read an older message in the thread + await msg.jumpTo(room2.name, "InThread0000", true); + + // Then the thread is still marked as unread + await util.backToThreadsList(); + await util.assertUnreadThread("ThreadRoot"); + }); + + test("Reading only one thread's message makes that thread read but not others", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have unread threads + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + "Msg2", + msg.threadedOff("Msg1", "Resp1"), + msg.threadedOff("Msg2", "Resp2"), + ]); + await util.assertUnread(room2, 2); // (Sanity) + await util.goTo(room2); + await util.assertRead(room2); + await util.assertUnreadThread("Msg1"); + await util.assertUnreadThread("Msg2"); + + // When I read one of them + await util.openThread("Msg1"); + + // Then that one is read, but the other is not + await util.assertReadThread("Msg1"); + await util.assertUnreadThread("Msg2"); + }); + + test("Reading the main timeline does not mark a thread message as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.threadedOff("Msg1", "Resp2"), + ]); + await util.assertUnread(room2, 1); // (Sanity) + + // When I read the main timeline + await util.goTo(room2); + await util.assertRead(room2); + + // Then thread does appear unread + await util.assertUnreadThread("Msg1"); + }); + + test("Marking a room with unread threads as read makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have an unread thread + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.threadedOff("Msg1", "Resp2"), + ]); + await util.assertUnread(room2, 1); // (Sanity) + + // When I mark the room as read + await util.markAsRead(room2); + + // Then the room is read + await util.assertRead(room2); + // and so are the threads + await util.assertReadThread("Msg1"); + }); + + test("Sending a new thread message after marking as read makes it unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.threadedOff("Msg1", "Resp2"), + ]); + + // When I mark the room as read + await util.markAsRead(room2); + await util.assertRead(room2); + + // Then another message appears in the thread + await util.receiveMessages(room2, [msg.threadedOff("Msg1", "Resp3")]); + + // Then the thread becomes unread + await util.goTo(room2); + await util.assertUnreadThread("Msg1"); + }); + + test("Sending a new different-thread message after marking as read makes it unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given 2 threads exist, and Thread2 has the latest message in it + await util.goTo(room1); + await util.receiveMessages(room2, ["Thread1", "Thread2", msg.threadedOff("Thread1", "t1a")]); + // Make sure the message in Thread 1 has definitely arrived, so that we know for sure + // that the one in Thread 2 is the latest. + + await util.receiveMessages(room2, [msg.threadedOff("Thread2", "t2a")]); + + // When I mark the room as read (making an unthreaded receipt for t2a) + await util.markAsRead(room2); + await util.assertRead(room2); + + // Then another message appears in the other thread + await util.receiveMessages(room2, [msg.threadedOff("Thread1", "t1b")]); + + // Then the other thread becomes unread + await util.goTo(room2); + await util.assertUnreadThread("Thread1"); + }); + + test("A room with a new threaded message is still unread after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.threadedOff("Msg1", "Resp2"), + ]); + await util.assertUnread(room2, 1); // (Sanity) + + // When I read the main timeline + await util.goTo(room2); + + // Then room appears read + await util.assertRead(room2); + /// but with an unread thread + await util.assertUnreadThread("Msg1"); + + await util.saveAndReload(); + await util.assertRead(room2); + await util.goTo(room2); + await util.assertUnreadThread("Msg1"); + + // Opening the thread now marks it as read + await util.openThread("Msg1"); + await util.assertReadThread("Msg1"); + }); + + test("A room where all threaded messages are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read all the threads + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.threadedOff("Msg1", "Resp2"), + ]); + await util.assertUnread(room2, 1); // (Sanity) + await util.goTo(room2); + await util.assertRead(room2); + await util.assertUnreadThread("Msg1"); + await util.openThread("Msg1"); + await util.assertReadThread("Msg1"); + + // When I restart + await util.saveAndReload(); + + // Then the room & thread still read + await util.assertRead(room2); + await util.goTo(room2); + await util.assertReadThread("Msg1"); + }); + }); + + test.describe("thread roots", () => { + test("Reading a thread root does not mark the thread as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); // (Sanity) + + // When I read the main timeline + await util.goTo(room2); + + // Then room doesn't appear unread but the thread does + await util.assertRead(room2); + await util.assertUnreadThread("Msg1"); + }); + + test("Reading a thread root within the thread view marks it as read in the main timeline", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given lots of messages are on the main timeline, and one has a thread off it + await util.goTo(room1); + await util.receiveMessages(room2, [ + ...many("beforeThread", 30), + "ThreadRoot", + msg.threadedOff("ThreadRoot", "InThread"), + ...many("afterThread", 30), + ]); + await util.assertUnread(room2, 61); // Sanity + + // 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, + // 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, + roomBeta: room2, + util, + msg, + }) => { + // Given a message and reply exist and are read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + await util.assertRead(room2); + + // When I receive a thread message created on the reply + await util.receiveMessages(room2, [msg.threadedOff("Reply1", "Resp1")]); + + // Then the thread is unread + await util.goTo(room2); + await util.assertUnreadThread("Reply1"); + }); + + test("Reading a thread whose root is a reply makes the thread read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread thread off a reply exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.replyTo("Msg1", "Reply1"), + msg.threadedOff("Reply1", "Resp1"), + ]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.assertUnreadThread("Reply1"); + + // When I read the thread + await util.openThread("Reply1"); + + // Then the room and thread are read + await util.assertRead(room2); + await util.assertReadThread("Reply1"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/reactions.spec.ts b/playwright/e2e/read-receipts/reactions.spec.ts new file mode 100644 index 00000000000..69208e5fc9e --- /dev/null +++ b/playwright/e2e/read-receipts/reactions.spec.ts @@ -0,0 +1,377 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test, expect } from "."; + +test.describe("Read receipts", () => { + test.describe("reactions", () => { + test.describe("in the main timeline", () => { + test("Receiving a reaction to a message does not make a room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + // When I read the main timeline + await util.goTo(room2); + await util.assertRead(room2); + + await util.goTo(room1); + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + await util.assertRead(room2); + }); + test("Reacting to a message after marking as read does not make the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + await util.assertRead(room2); + }); + test("A room with an unread reaction is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + await util.assertRead(room2); + + await util.saveAndReload(); + await util.assertRead(room2); + }); + test("A room where all reactions are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2", msg.reactionTo("Msg2", "🪿")]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.saveAndReload(); + await util.assertRead(room2); + }); + }); + + test.describe("in threads", () => { + test("A reaction to a threaded message does not make the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists and I have read it + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + await util.goTo(room1); + + // When someone reacts to a thread message + await util.receiveMessages(room2, [msg.reactionTo("Reply1", "🪿")]); + + // Then the room remains read + await util.assertStillRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Marking a room as read after a reaction in a thread makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists with a reaction + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Reply1"), + msg.reactionTo("Reply1", "🪿"), + ]); + await util.assertUnread(room2, 1); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then it becomes read + await util.assertRead(room2); + }); + + test("Reacting to a thread message after marking as read does not make the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists and I have marked it as read + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Reply1"), + msg.reactionTo("Reply1", "🪿"), + ]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When someone reacts to a thread message + await util.receiveMessages(room2, [msg.reactionTo("Reply1", "🪿")]); + + // Then the room remains read + await util.assertStillRead(room2); + // as does the thread + await util.assertReadThread("Msg1"); + }); + + test("A room with a reaction to a threaded message is still unread after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists and I have read it + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + + // And someone reacted to it, which doesn't make it read + await util.receiveMessages(room2, [msg.reactionTo("Reply1", "🪿")]); + await util.assertStillRead(room2); + await util.assertReadThread("Msg1"); + + // When I restart + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("A room where all reactions in threads are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given multiple threads with reactions exist and are read + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Reply1a"), + msg.reactionTo("Reply1a", "r"), + "Msg2", + msg.threadedOff("Msg1", "Reply1b"), + msg.threadedOff("Msg2", "Reply2a"), + msg.reactionTo("Msg1", "e"), + msg.threadedOff("Msg2", "Reply2b"), + msg.reactionTo("Reply2a", "a"), + msg.reactionTo("Reply2b", "c"), + msg.reactionTo("Reply1b", "t"), + ]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertReadThread("Msg1"); + await util.openThread("Msg2"); + await util.assertReadThread("Msg2"); + await util.assertRead(room2); + await util.goTo(room1); + + // When I restart + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + await util.goTo(room2); + await util.assertReadThread("Msg1"); + await util.assertReadThread("Msg2"); + }); + + test("Can remove a reaction in a thread", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Note: this is not strictly a read receipt test, but it checks + // for a bug we caused when we were fixing unreads, so it's + // included here. The bug is: + // https://github.com/vector-im/element-web/issues/26498 + + // Given a thread exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1a")]); + await util.assertUnread(room2, 1); + + // When I react to a thread message + await util.goTo(room2); + await util.openThread("Msg1"); + await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover(); + await page.getByRole("button", { name: "React" }).click(); + await page.locator(".mx_EmojiPicker_body").getByText("😀").click(); + + // And cancel the reaction + await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀").click(); + + // Then it disappears + await expect(page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible(); + + // And I can do it all again without an error + await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover(); + await page.getByRole("button", { name: "React" }).click(); + await page.locator(".mx_EmojiPicker_body").getByText("😀").first().click(); + await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀").click(); + await expect(await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible(); + }); + }); + + test.describe("thread roots", () => { + test("A reaction to a thread root does not make the room unread", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a read thread root exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When someone reacts to it + await util.goTo(room1); + await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); + await page.waitForTimeout(200); + + // Then the room is still read + await util.assertRead(room2); + // as is the thread + await util.assertReadThread("Msg1"); + }); + + test("Reading a reaction to a thread root leaves the room read", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a read thread root exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + + // And the reaction to it does not make us unread + await util.goTo(room1); + await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When we read the reaction and go away again + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + await page.waitForTimeout(200); + + // Then the room is still read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Reacting to a thread root after marking as read makes the room unread but not the thread", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread root exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + + // And we have marked the room as read + await util.markAsRead(room2); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When someone reacts to it + await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); + await page.waitForTimeout(200); + + // Then the room is still read + await util.assertRead(room2); + // as is the thread + await util.assertReadThread("Msg1"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/read-receipts.spec.ts b/playwright/e2e/read-receipts/read-receipts.spec.ts new file mode 100644 index 00000000000..dac679f6a07 --- /dev/null +++ b/playwright/e2e/read-receipts/read-receipts.spec.ts @@ -0,0 +1,345 @@ +/* +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 { JSHandle } from "@playwright/test"; +import type { MatrixEvent, ISendEventResponse, ReceiptType } from "matrix-js-sdk/src/matrix"; +import { expect } from "../../element-web-test"; +import { ElementAppPage } from "../../pages/ElementAppPage"; +import { Bot } from "../../pages/bot"; +import { test } from "."; + +test.describe("Read receipts", () => { + test.use({ + displayName: "Mae", + botCreateOpts: { displayName: "Other User" }, + }); + + const selectedRoomName = "Selected Room"; + const otherRoomName = "Other Room"; + + let otherRoomId: string; + let selectedRoomId: string; + + const sendMessage = async (bot: Bot, no = 1): Promise => { + return bot.sendMessage(otherRoomId, { body: `Message ${no}`, msgtype: "m.text" }); + }; + + const botSendThreadMessage = (bot: Bot, threadId: string): Promise => { + return bot.sendEvent(otherRoomId, threadId, "m.room.message", { body: "Message", msgtype: "m.text" }); + }; + + const fakeEventFromSent = ( + app: ElementAppPage, + eventResponse: ISendEventResponse, + threadRootId: string | undefined, + ): Promise> => { + return app.client.evaluateHandle( + (client, { otherRoomId, eventResponse, threadRootId }) => { + return { + getRoomId: () => otherRoomId, + getId: () => eventResponse.event_id, + threadRootId, + getTs: () => 1, + isRelation: (relType) => { + return !relType || relType === "m.thread"; + }, + } as any as MatrixEvent; + }, + { otherRoomId, eventResponse, threadRootId }, + ); + }; + + /** + * Send a threaded receipt marking the message referred to in + * eventResponse as read. If threadRootEventResponse is supplied, the + * receipt will have its event_id as the thread root ID for the receipt. + */ + const sendThreadedReadReceipt = async ( + app: ElementAppPage, + eventResponse: ISendEventResponse, + threadRootEventResponse: ISendEventResponse = undefined, + ) => { + await app.client.sendReadReceipt( + await fakeEventFromSent(app, eventResponse, threadRootEventResponse?.event_id), + ); + }; + + /** + * Send an unthreaded receipt marking the message referred to in + * eventResponse as read. + */ + const sendUnthreadedReadReceipt = async (app: ElementAppPage, eventResponse: ISendEventResponse) => { + await app.client.sendReadReceipt( + await fakeEventFromSent(app, eventResponse, undefined), + "m.read" as any as ReceiptType, + true, + ); + }; + + test.beforeEach(async ({ page, app, user, bot }) => { + /* + * Create 2 rooms: + * + * - Selected room - this one is clicked in the UI + * - Other room - this one contains the bot, which will send events so + * we can check its unread state. + */ + selectedRoomId = await app.client.createRoom({ name: selectedRoomName }); + // Invite the bot to Other room + otherRoomId = await app.client.createRoom({ name: otherRoomName, invite: [bot.credentials.userId] }); + + await page.goto(`/#/room/${otherRoomId}`); + await expect(page.getByText(`${bot.credentials.displayName} joined the room`)).toBeVisible(); + + // Then go into Selected room + await page.goto(`/#/room/${selectedRoomId}`); + }); + + // 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. + + // Given we sent 3 events on the main thread + await sendMessage(bot); + const main2 = await sendMessage(bot); + const main3 = await sendMessage(bot); + + // (So the room starts off unread) + await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible(); + + // When we send a threaded receipt for the last event in main + // And an unthreaded receipt for an earlier event + await sendThreadedReadReceipt(app, main3); + await sendUnthreadedReadReceipt(app, main2); + + // (So the room has no unreads) + await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + + // And we persuade the app to persist its state to indexeddb by reloading and waiting + await page.reload(); + await expect(page.getByLabel(`${selectedRoomName}`)).toBeVisible(); + + // And we reload again, fetching the persisted state FROM indexeddb + await page.reload(); + + // Then the room is read, because the persisted state correctly remembers both + // receipts. (In #24629, the unthreaded receipt overwrote the main thread one, + // meaning that the room still said it had unread messages.) + await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} Unread messages.`)).not.toBeVisible(); + }); + + test("Recognises unread messages on main thread after receiving a receipt for earlier ones", async ({ + page, + app, + bot, + }) => { + // Given we sent 3 events on the main thread + await sendMessage(bot); + const main2 = await sendMessage(bot); + await sendMessage(bot); + + // (The room starts off unread) + await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible(); + + // When we send a threaded receipt for the second-last event in main + await sendThreadedReadReceipt(app, main2); + + // Then the room has only one unread + await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + }); + + test("Considers room read if there is only a main thread and we have a main receipt", async ({ + page, + app, + bot, + }) => { + // Given we sent 3 events on the main thread + await sendMessage(bot); + await sendMessage(bot); + const main3 = await sendMessage(bot); + // (The room starts off unread) + await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible(); + + // When we send a threaded receipt for the last event in main + await sendThreadedReadReceipt(app, main3); + + // Then the room has no unreads + await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + }); + + test("Recognises unread messages on other thread after receiving a receipt for earlier ones", async ({ + page, + app, + bot, + util, + }) => { + // Given we sent 3 events on the main thread + const main1 = await sendMessage(bot); + const thread1a = await botSendThreadMessage(bot, main1.event_id); + await botSendThreadMessage(bot, main1.event_id); + // 1 unread on the main thread, 2 in the new thread that aren't shown + await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + + // When we send receipts for main, and the second-last in the thread + await sendThreadedReadReceipt(app, main1); + await sendThreadedReadReceipt(app, thread1a, main1); + + // Then the room has only one unread - the one in the thread + await util.goTo(otherRoomName); + await util.assertUnreadThread("Message 1"); + }); + + test("Considers room read if there are receipts for main and other thread", async ({ page, app, bot, util }) => { + // Given we sent 3 events on the main thread + const main1 = await sendMessage(bot); + await botSendThreadMessage(bot, main1.event_id); + const thread1b = await botSendThreadMessage(bot, main1.event_id); + // 1 unread on the main thread, 2 in the new thread which don't show + await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + + // When we send receipts for main, and the last in the thread + await sendThreadedReadReceipt(app, main1); + await sendThreadedReadReceipt(app, thread1b, main1); + + // Then the room has no unreads + await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + await util.goTo(otherRoomName); + await util.assertReadThread("Message 1"); + }); + + test("Recognises unread messages on a thread after receiving a unthreaded receipt for earlier ones", async ({ + page, + app, + bot, + util, + }) => { + // Given we sent 3 events on the main thread + const main1 = await sendMessage(bot); + const thread1a = await botSendThreadMessage(bot, main1.event_id); + await botSendThreadMessage(bot, main1.event_id); + // 1 unread on the main thread, 2 in the new thread which don't count + await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + + // When we send an unthreaded receipt for the second-last in the thread + await sendUnthreadedReadReceipt(app, thread1a); + + // Then the room has only one unread - the one in the + // thread. The one in main is read because the unthreaded + // receipt is for a later event. The room should therefore be + // read, and the thread unread. + await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + await util.goTo(otherRoomName); + await util.assertUnreadThread("Message 1"); + }); + + test("Recognises unread messages on main after receiving a unthreaded receipt for a thread message", async ({ + page, + app, + bot, + }) => { + // Given we sent 3 events on the main thread + const main1 = await sendMessage(bot); + await botSendThreadMessage(bot, main1.event_id); + const thread1b = await botSendThreadMessage(bot, main1.event_id); + await sendMessage(bot); + // 2 unreads on the main thread, 2 in the new thread which don't count + await expect(page.getByLabel(`${otherRoomName} 2 unread messages.`)).toBeVisible(); + + // When we send an unthreaded receipt for the last in the thread + await sendUnthreadedReadReceipt(app, thread1b); + + // Then the room has only one unread - the one in the + // main thread, because it is later than the unthreaded + // receipt. + await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + }); + + /** + * The idea of this test is to intercept the receipt / read read_markers requests and + * assert that the correct ones are sent. + * Prose playbook: + * - Another user sends enough messages that the timeline becomes scrollable + * - The current user looks at the room and jumps directly to the first unread message + * - At this point, a receipt for the last message in the room and + * a fully read marker for the last visible message are expected to be sent + * - Then the user jumps to the end of the timeline + * - A fully read marker for the last message in the room is expected to be sent + */ + test("Should send the correct receipts", async ({ page, bot }) => { + const uriEncodedOtherRoomId = encodeURIComponent(otherRoomId); + + const receiptRequestPromise = page.waitForRequest( + new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/receipt/m\\.read/.+`), + ); + + const numberOfMessages = 20; + const sendMessageResponses: ISendEventResponse[] = []; + + for (let i = 1; i <= numberOfMessages; i++) { + sendMessageResponses.push(await sendMessage(bot, i)); + } + + const lastMessageId = sendMessageResponses.at(-1).event_id; + const uriEncodedLastMessageId = encodeURIComponent(lastMessageId); + + // wait until all messages have been received + await expect(page.getByLabel(`${otherRoomName} ${sendMessageResponses.length} unread messages.`)).toBeVisible(); + + // switch to the room with the messages + await page.goto(`/#/room/${otherRoomId}`); + + const receiptRequest = await receiptRequestPromise; + // assert the read receipt for the last message in the room + expect(receiptRequest.url()).toContain(uriEncodedLastMessageId); + expect(receiptRequest.postDataJSON()).toEqual({ + thread_id: "main", + }); + + // the following code tests the fully read marker somewhere in the middle of the room + const readMarkersRequestPromise = page.waitForRequest( + new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/read_markers`), + ); + + await page.getByRole("button", { name: "Jump to first unread message." }).click(); + + const readMarkersRequest = await readMarkersRequestPromise; + // since this is not pixel perfect, + // the fully read marker should be +/- 1 around the last visible message + expect([ + sendMessageResponses[11].event_id, + sendMessageResponses[12].event_id, + sendMessageResponses[13].event_id, + ]).toContain(readMarkersRequest.postDataJSON()["m.fully_read"]); + + // the following code tests the fully read marker at the bottom of the room + const readMarkersRequestPromise2 = page.waitForRequest( + new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/read_markers`), + ); + + await page.getByRole("button", { name: "Scroll to most recent messages" }).click(); + + const readMarkersRequest2 = await readMarkersRequestPromise2; + expect(readMarkersRequest2.postDataJSON()).toEqual({ + ["m.fully_read"]: sendMessageResponses.at(-1).event_id, + }); + }); +}); diff --git a/playwright/e2e/read-receipts/readme.md b/playwright/e2e/read-receipts/readme.md new file mode 100644 index 00000000000..4e4dce297f5 --- /dev/null +++ b/playwright/e2e/read-receipts/readme.md @@ -0,0 +1,20 @@ +# High Level Read Receipt Tests + +Tips for writing these tests: + +- Break up your tests into the smallest test case possible. The purpose of + these tests is to understand hard-to-find bugs, so small tests are necessary. + We know that Playwright recommends combining tests together for performance, but + that will frustrate our goals here. (We will need to find a different way to + reduce CI time.) + +- Try to assert something after every action, to make sure it has completed. + E.g.: + markAsRead(room2); + assertRead(room2); + You should especially follow this rule if you are jumping to a different + room or similar straight afterward. + +- Use assertStillRead() if you are asserting something is read when it was + also read before. This waits a little while to make sure you're not getting a + false positive. diff --git a/playwright/e2e/read-receipts/redactions.spec.ts b/playwright/e2e/read-receipts/redactions.spec.ts new file mode 100644 index 00000000000..f7affbed212 --- /dev/null +++ b/playwright/e2e/read-receipts/redactions.spec.ts @@ -0,0 +1,1079 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("redactions", () => { + test.describe("in the main timeline", () => { + test("Redacting the message pointed to by my receipt leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read the messages in a room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When the latest message is redacted + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + + // Then the room remains read + await util.assertStillRead(room2); + }); + + test("Reading an unread room after a redaction of the latest message makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + // And the latest message has been redacted + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + + // When I read the room + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // Then it becomes read + await util.assertStillRead(room2); + }); + test("Reading an unread room after a redaction of an older message makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room with an earlier redaction + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + + // When I read the room + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // Then it becomes read + await util.assertStillRead(room2); + }); + test("Marking an unread room as read after a redaction makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room where latest message is redacted + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // When I mark it as read + await util.markAsRead(room2); + + // Then it becomes read + await util.assertRead(room2); + }); + test("Sending and redacting a message after marking the room as read makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a room that is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When a message is sent and then redacted + await util.receiveMessages(room2, ["Msg3"]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // Then the room is read + await util.assertRead(room2); + }); + test("Redacting a message after marking the room as read leaves it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a room that is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); + await util.assertUnread(room2, 3); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When we redact some messages + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + + // Then it is still read + await util.assertStillRead(room2); + }); + test("Redacting one of the unread messages reduces the unread count", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); + await util.assertUnread(room2, 3); + + // When I redact a non-latest message + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + + // Then the unread count goes down + await util.assertUnread(room2, 2); + + // And when I redact the latest message + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // Then the unread count goes down again + await util.assertUnread(room2, 1); + }); + test("Redacting one of the unread messages reduces the unread count after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given unread count was reduced by redacting messages + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); + await util.assertUnread(room2, 3); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + await util.assertUnread(room2, 1); + + // When I restart + await util.saveAndReload(); + + // Then the unread count is still reduced + await util.assertUnread(room2, 1); + }); + test("Redacting all unread messages makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + // When I redact all the unread messages + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + + // Then the room is back to being read + await util.assertRead(room2); + }); + test("Redacting all unread messages makes the room read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given all unread messages were redacted + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + }); + test("Reacting to a redacted message leaves the room read", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message exists + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // And the room is read + await util.goTo(room2); + await util.assertRead(room2); + await page.waitForTimeout(200); + await util.goTo(room1); + + // When I react to the redacted message + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + + // Then the room is still read + await util.assertStillRead(room2); + }); + test("Editing a redacted message leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message exists + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // And the room is read + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When I attempt to edit the redacted message + await util.receiveMessages(room2, [msg.editOf("Msg2", "Msg2 is BACK")]); + + // Then the room is still read + await util.assertStillRead(room2); + }); + test("A reply to a redacted message makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message was redacted + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // And the room is read + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When I receive a reply to the redacted message + await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); + + // Then the room is unread + await util.assertUnread(room2, 1); + }); + test("Reading a reply to a redacted message marks the room as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given someone replied to a redacted message + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); + await util.assertUnread(room2, 1); + + // When I read the reply + await util.goTo(room2); + await util.assertRead(room2); + + // Then the room is unread + await util.goTo(room1); + await util.assertStillRead(room2); + }); + }); + + test.describe("in threads", () => { + test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have some threads + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root1", + msg.threadedOff("Root1", "ThreadMsg1"), + msg.threadedOff("Root1", "ThreadMsg2"), + "Root2", + msg.threadedOff("Root2", "Root2->A"), + ]); + await util.assertUnread(room2, 2); + + await util.goTo(room2); + await util.assertUnreadThread("Root1"); + await util.assertUnreadThread("Root2"); + + // And I have read them + await util.assertUnreadThread("Root1"); + await util.openThread("Root1"); + await util.assertRead(room2); + await util.backToThreadsList(); + await util.assertReadThread("Root1"); + + await util.openThread("Root2"); + await util.assertReadThread("Root2"); + await util.closeThreadsPanel(); + await util.goTo(room1); + + // When the latest message in a thread is redacted + await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg2")]); + + // Then the room and thread are still read + await util.assertStillRead(room2); + await util.goTo(room2); + await util.assertReadThread("Root1"); + }); + + test("Reading an unread thread after a redaction of the latest message makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread thread where the latest message was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "ThreadMsg1"), + msg.threadedOff("Root", "ThreadMsg2"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg2")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertUnreadThread("Root"); + + // When I read the thread + await util.openThread("Root"); + await util.assertRead(room2); + await util.closeThreadsPanel(); + await util.goTo(room1); + + // Then the thread is read + await util.assertRead(room2); + await util.goTo(room2); + await util.assertReadThread("Root"); + }); + + test("Reading an unread thread after a redaction of the latest message makes it read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message is not counted in the unread count + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "ThreadMsg1"), + msg.threadedOff("Root", "ThreadMsg2"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg2")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertUnreadThread("Root"); + await util.openThread("Root"); + await util.assertRead(room2); + await util.closeThreadsPanel(); + await util.goTo(room1); + await util.assertRead(room2); + await util.goTo(room2); + await util.assertReadThread("Root"); + + // When I restart + await util.saveAndReload(); + + // Then the room and thread are still read + await util.assertRead(room2); + await util.openThreadList(); + await util.assertReadThread("Root"); + }); + + test("Reading an unread thread after a redaction of an older message makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread thread where an older message was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "ThreadMsg1"), + msg.threadedOff("Root", "ThreadMsg2"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertUnreadThread("Root"); + + // When I read the thread + await util.openThread("Root"); + await util.assertRead(room2); + await util.closeThreadsPanel(); + await util.goTo(room1); + + // Then the thread is read + await util.assertRead(room2); + await util.goTo(room2); + await util.assertReadThread("Root"); + }); + + test("Marking an unread thread as read after a redaction makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread thread where an older message was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "ThreadMsg1"), + msg.threadedOff("Root", "ThreadMsg2"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg1")]); + await util.assertUnread(room2, 1); + + // When I mark the room as read + await util.markAsRead(room2); + await util.assertRead(room2); + + // Then the thread is read + await util.assertRead(room2); + await util.goTo(room2); + await util.assertReadThread("Root"); + }); + + test("Sending and redacting a message after marking the thread as read leaves it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists and is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "ThreadMsg1"), + msg.threadedOff("Root", "ThreadMsg2"), + ]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I send and redact a message + await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg3")]); + await util.goTo(room2); + await util.openThreadList(); + await util.assertUnreadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // Then the room and thread are read + await util.goTo(room2); + await util.assertReadThread("Root"); + }); + + test("Redacting a message after marking the thread as read leaves it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists and is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "ThreadMsg1"), + msg.threadedOff("Root", "ThreadMsg2"), + ]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I redact a message + await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg1")]); + + // Then the room and thread are read + await util.assertRead(room2); + await util.goTo(room2); + await util.assertReadThread("Root"); + }); + + test("Reacting to a redacted message leaves the thread read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message in a thread was redacted and everything is read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.backToThreadsList(); + await util.assertReadThread("Root"); + await util.goTo(room1); + + // When we receive a reaction to the redacted event + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "z")]); + + // Then the room is read + await util.assertStillRead(room2); + await util.goTo(room2); + await util.openThreadList(); + await util.assertReadThread("Root"); + }); + + test("Editing a redacted message leaves the thread read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message in a thread was redacted and everything is read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + await util.openThreadList(); + await util.assertUnreadThread("Root"); + await util.openThread("Root"); + await util.assertReadThread("Root"); + await util.goTo(room1); + + // When we receive an edit of the redacted message + await util.receiveMessages(room2, [msg.editOf("Msg2", "New Msg2")]); + + // Then the room is unread + await util.assertStillRead(room2); + // and so is the thread + await util.goTo(room2); + await util.openThreadList(); + await util.assertReadThread("Root"); + }); + + test("Reading a thread after a reaction to a redacted message marks the thread as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message in a thread exists, but someone reacted to it before it was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + msg.reactionTo("Msg3", "x"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + await util.assertUnread(room2, 1); + + // When we read the thread + await util.goTo(room2); + await util.openThread("Root"); + + // Then the thread (and room) are read + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + + test("Reading a thread containing a redacted, edited message marks the thread as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message in a thread exists, but someone edited it before it was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + msg.editOf("Msg3", "Msg3 Edited"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // When we read the thread + await util.goTo(room2); + await util.openThread("Root"); + + // Then the thread (and room) are read + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + + test("Reading a reply to a redacted message marks the thread as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message in a thread exists, but someone replied before it was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + msg.replyTo("Msg3", "Msg3Reply"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // When we read the thread, creating a receipt that points at the edit + await util.goTo(room2); + await util.openThread("Root"); + + // Then the thread (and room) are read + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + + test("Reading a thread root when its only message has been redacted leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given we had a thread + await util.goTo(room1); + await util.receiveMessages(room2, ["Root", msg.threadedOff("Root", "Msg2")]); + await util.assertUnread(room2, 1); + + // And then redacted the message that makes it a thread + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // When we read the main timeline + await util.goTo(room2); + + // Then the room is read + await util.assertRead(room2); + // and that thread is read + await util.openThreadList(); + await util.assertReadThread("Root"); + }); + + test("A thread with a redacted unread is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I sent and redacted a message in an otherwise-read thread + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "ThreadMsg1"), + msg.threadedOff("Root", "ThreadMsg2"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg3")]); + await util.assertRead(room2); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + await util.assertRead(room2); + await util.goTo(room2); + await util.assertReadThread("Root"); + await util.goTo(room1); + + // When I restart + await util.saveAndReload(); + + // Then the room and thread are still read + await util.assertRead(room2); + await util.goTo(room2); + await util.assertReadThread("Root"); + }); + + /* + * Disabled: this doesn't seem to work as, at some point after syncing from cache, the redaction and redacted + * event get removed from the thread timeline such that we have no record of the events that the read receipt + * points to. I suspect this may have been passing by fluke before. + */ + test.skip("A thread with a read redaction is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given my receipt points at a redacted thread message + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root1", + msg.threadedOff("Root1", "ThreadMsg1"), + msg.threadedOff("Root1", "ThreadMsg2"), + "Root2", + msg.threadedOff("Root2", "Root2->A"), + ]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertUnreadThread("Root1"); + await util.openThread("Root1"); + await util.assertRead(room2); + await util.openThread("Root2"); + await util.assertRead(room2); + await util.closeThreadsPanel(); + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg2")]); + await util.assertStillRead(room2); + await util.goTo(room2); + await util.assertReadThread("Root1"); + + // When I restart + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + // and so is the thread + await util.openThreadList(); + await util.assertReadThread("Root1"); + await util.assertReadThread("Root2"); + }); + + test("A thread with an unread reply to a redacted message is still unread after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message in a thread exists, but someone replied before it was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + msg.replyTo("Msg3", "Msg3Reply"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // And we have read all this + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + + // When I restart + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + + test("A thread with a read reply to a redacted message is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message in a thread exists, but someone replied before it was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + msg.replyTo("Msg3", "Msg3Reply"), + ]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // And I read it, so the room is read + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + + // When I restart + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + }); + + test.describe("thread roots", () => { + test("Redacting a thread root after it was read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + test.slow(); + + // Given a thread exists and is read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + + // When someone redacts the thread root + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // Then the room is still read + await util.assertStillRead(room2); + }); + + /* + * Disabled for the same reason as "A thread with a read redaction is still read after restart" + * above + */ + test.skip("Redacting a thread root still allows us to read the thread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread thread exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + + // When someone redacts the thread root + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // Then the room is still unread + await util.assertUnread(room2, 1); + + // And I can open the thread and read it + await util.goTo(room2); + await util.assertRead(room2); + // 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"); + }); + + test("Sending a threaded message onto a redacted thread root leaves the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and its root is redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When we receive a new message on it + await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg4")]); + + // Then the room is read but the thread is unread + await util.assertRead(room2); + await util.goTo(room2); + await util.assertUnreadThread("Message deleted"); + }); + + test("Reacting to a redacted thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When I react to the old root + await util.receiveMessages(room2, [msg.reactionTo("Root", "y")]); + + // Then the room is still read + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + + test("Editing a redacted thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When I edit the old root + await util.receiveMessages(room2, [msg.editOf("Root", "New Root")]); + + // Then the room is still read + await util.assertRead(room2); + // as is the thread + await util.assertReadThread("Root"); + }); + + test("Replying to a redacted thread root makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When I reply to the old root + await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); + + // Then the room is unread + await util.assertUnread(room2, 1); + }); + + test("Reading a reply to a redacted thread root makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted, and + // someone replied to it + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + await util.assertStillRead(room2); + await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); + await util.assertUnread(room2, 1); + + // When I read the room + await util.goTo(room2); + + // Then it becomes read + await util.assertRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/register/email.spec.ts b/playwright/e2e/register/email.spec.ts new file mode 100644 index 00000000000..3ab408ae2f8 --- /dev/null +++ b/playwright/e2e/register/email.spec.ts @@ -0,0 +1,79 @@ +/* +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 { isDendrite } from "../../plugins/homeserver/dendrite"; + +test.describe("Email Registration", async () => { + test.skip(isDendrite, "not yet wired up"); + + test.use({ + startHomeserverOpts: ({ mailhog }, use) => + use({ + template: "email", + variables: { + SMTP_HOST: "host.containers.internal", + SMTP_PORT: mailhog.instance.smtpPort, + }, + }), + config: ({ homeserver }, use) => + use({ + default_server_config: { + "m.homeserver": { + base_url: homeserver.config.baseUrl, + }, + "m.identity_server": { + base_url: "https://server.invalid", + }, + }, + }), + }); + + test.beforeEach(async ({ page }) => { + await page.goto("/#/register"); + }); + + test("registers an account and lands on the use case selection screen", async ({ + page, + mailhog, + request, + checkA11y, + }) => { + await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible(); + // Hide the server text as it contains the randomly allocated Homeserver port + const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] }; + + await page.getByRole("textbox", { name: "Username" }).fill("alice"); + await page.getByPlaceholder("Password", { exact: true }).fill("totally a great password"); + await page.getByPlaceholder("Confirm password").fill("totally a great password"); + await page.getByPlaceholder("Email").fill("alice@email.com"); + await page.getByRole("button", { name: "Register" }).click(); + + await expect(page.getByText("Check your email to continue")).toBeVisible(); + await expect(page).toMatchScreenshot("registration_check_your_email.png", screenshotOptions); + await checkA11y(); + + await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible(); + + const messages = await mailhog.api.messages(); + expect(messages.items).toHaveLength(1); + expect(messages.items[0].to).toEqual("alice@email.com"); + const [emailLink] = messages.items[0].text.match(/http.+/); + await request.get(emailLink); // "Click" the link in the email + + await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible(); + }); +}); diff --git a/playwright/e2e/register/register.spec.ts b/playwright/e2e/register/register.spec.ts new file mode 100644 index 00000000000..900012d8fa5 --- /dev/null +++ b/playwright/e2e/register/register.spec.ts @@ -0,0 +1,124 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Registration", () => { + test.use({ startHomeserverOpts: "consent" }); + + test.beforeEach(async ({ page }) => { + await page.goto("/#/register"); + }); + + test("registers an account and lands on the home screen", async ({ homeserver, page, checkA11y, crypto }) => { + await page.getByRole("button", { name: "Edit", exact: true }).click(); + await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible(); + + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("server-picker.png"); + await checkA11y(); + + await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + // wait for the dialog to go away + await expect(page.getByRole("dialog")).not.toBeVisible(); + + await expect(page.getByRole("textbox", { name: "Username", exact: true })).toBeVisible(); + // Hide the server text as it contains the randomly allocated Homeserver port + const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] }; + await expect(page).toMatchScreenshot("registration.png", screenshotOptions); + await checkA11y(); + + await page.getByRole("textbox", { name: "Username", exact: true }).fill("alice"); + await page.getByPlaceholder("Password", { exact: true }).fill("totally a great password"); + await page.getByPlaceholder("Confirm password", { exact: true }).fill("totally a great password"); + await page.getByRole("button", { name: "Register", exact: true }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect(page).toMatchScreenshot("email-prompt.png", screenshotOptions); + await checkA11y(); + await dialog.getByRole("button", { name: "Continue", exact: true }).click(); + + await expect(page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy")).toBeVisible(); + await expect(page).toMatchScreenshot("terms-prompt.png", screenshotOptions); + await checkA11y(); + + const termsPolicy = page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy"); + await termsPolicy.getByRole("checkbox").click(); // Click the checkbox before terms of service anchor link + await expect(termsPolicy.getByLabel("Privacy Policy")).toBeVisible(); + + await page.getByRole("button", { name: "Accept", exact: true }).click(); + + await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible(); + await expect(page).toMatchScreenshot("use-case-selection.png", screenshotOptions); + await checkA11y(); + await page.getByRole("button", { name: "Skip", exact: true }).click(); + + await expect(page).toHaveURL(/\/#\/home$/); + + /* + * Cross-signing checks + */ + // check that the device considers itself verified + await page.getByRole("button", { name: "User menu", exact: true }).click(); + await page.getByRole("menuitem", { name: "All settings", exact: true }).click(); + await page.getByRole("tab", { name: "Sessions", exact: true }).click(); + await expect(page.getByTestId("current-session-section").getByTestId("device-metadata-isVerified")).toHaveText( + "Verified", + ); + + // check that cross-signing keys have been uploaded. + await crypto.assertDeviceIsCrossSigned(); + }); + + test("should require username to fulfil requirements and be available", async ({ homeserver, page }) => { + await page.getByRole("button", { name: "Edit", exact: true }).click(); + await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible(); + await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + // wait for the dialog to go away + await expect(page.getByRole("dialog")).not.toBeVisible(); + + await expect(page.getByRole("textbox", { name: "Username", exact: true })).toBeVisible(); + + await page.route("**/_matrix/client/*/register/available?username=_alice", async (route) => { + await route.fulfill({ + status: 400, + json: { + errcode: "M_INVALID_USERNAME", + error: "User ID may not begin with _", + }, + }); + }); + await page.getByRole("textbox", { name: "Username", exact: true }).fill("_alice"); + await expect(page.getByRole("alert").filter({ hasText: "Some characters not allowed" })).toBeVisible(); + + await page.route("**/_matrix/client/*/register/available?username=bob", async (route) => { + await route.fulfill({ + status: 400, + json: { + errcode: "M_USER_IN_USE", + error: "The desired username is already taken", + }, + }); + }); + await page.getByRole("textbox", { name: "Username", exact: true }).fill("bob"); + await expect(page.getByRole("alert").filter({ hasText: "Someone already has that username" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Username", exact: true }).fill("foobar"); + await expect(page.getByRole("alert")).not.toBeVisible(); + }); +}); diff --git a/playwright/e2e/regression-tests/pills-click-in-app.spec.ts b/playwright/e2e/regression-tests/pills-click-in-app.spec.ts new file mode 100644 index 00000000000..6a038db43d8 --- /dev/null +++ b/playwright/e2e/regression-tests/pills-click-in-app.spec.ts @@ -0,0 +1,60 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Pills", () => { + test.use({ + displayName: "Sally", + }); + + test("should navigate clicks internally to the app", async ({ page, app, user }) => { + const messageRoom = "Send Messages Here"; + const targetLocalpart = "aliasssssssssssss"; + await app.client.createRoom({ + name: "Target", + room_alias_name: targetLocalpart, + }); + const messageRoomId = await app.client.createRoom({ + name: messageRoom, + }); + + await app.viewRoomByName(messageRoom); + await expect(page).toHaveURL(new RegExp(`/#/room/${messageRoomId}`)); + + // send a message using the built-in room mention functionality (autocomplete) + await page + .getByRole("textbox", { name: "Send a message…" }) + .pressSequentially(`Hello world! Join here: #${targetLocalpart.substring(0, 3)}`); + await page.locator(".mx_Autocomplete_Completion_title").click(); + await page.getByRole("button", { name: "Send message" }).click(); + + // find the pill in the timeline and click it + await page.locator(".mx_EventTile_body .mx_Pill").click(); + + const localUrl = new RegExp(`/#/room/#${targetLocalpart}:`); + // verify we landed at a sane place + await expect(page).toHaveURL(localUrl); + + // go back to the message room and try to click on the pill text, as a user would + await app.viewRoomByName(messageRoom); + const pillText = page.locator(".mx_EventTile_body .mx_Pill .mx_Pill_text"); + await expect(pillText).toHaveCSS("pointer-events", "none"); + await pillText.click({ force: true }); // force is to ensure we bypass pointer-events + + await expect(page).toHaveURL(localUrl); + }); +}); diff --git a/playwright/e2e/release-announcement/index.ts b/playwright/e2e/release-announcement/index.ts new file mode 100644 index 00000000000..d5ea4f29175 --- /dev/null +++ b/playwright/e2e/release-announcement/index.ts @@ -0,0 +1,77 @@ +/* + * + * Copyright 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. + * / + */ + +import { Page } from "@playwright/test"; + +import { test as base, expect } from "../../element-web-test"; + +/** + * Set up for release announcement tests. + */ +export const test = base.extend<{ + util: Helpers; +}>({ + displayName: "Alice", + botCreateOpts: { displayName: "Other User" }, + + util: async ({ page, app, bot }, use) => { + await use(new Helpers(page)); + }, +}); + +export class Helpers { + constructor(private page: Page) {} + + /** + * Get the release announcement with the given name. + * @param name + * @private + */ + private getReleaseAnnouncement(name: string) { + return this.page.getByRole("dialog", { name }); + } + + /** + * Assert that the release announcement with the given name is visible. + * @param name + */ + async assertReleaseAnnouncementIsVisible(name: string) { + await expect(this.getReleaseAnnouncement(name)).toBeVisible(); + await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`); + } + + /** + * Assert that the release announcement with the given name is not visible. + * @param name + */ + assertReleaseAnnouncementIsNotVisible(name: string) { + return expect(this.getReleaseAnnouncement(name)).not.toBeVisible(); + } + + /** + * Mark the release announcement with the given name as read. + * If the release announcement is not visible, this will throw an error. + * @param name + */ + async markReleaseAnnouncementAsRead(name: string) { + const dialog = this.getReleaseAnnouncement(name); + await dialog.getByRole("button", { name: "Ok" }).click(); + } +} + +export { expect }; diff --git a/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts b/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts new file mode 100644 index 00000000000..24854560c85 --- /dev/null +++ b/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts @@ -0,0 +1,44 @@ +/* + * + * Copyright 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. + * / + */ + +import { test, expect } from "./"; + +test.describe("Release announcement", () => { + test.use({ + config: { + features: { + feature_release_announcement: true, + }, + }, + labsFlags: ["threadsActivityCentre"], + }); + + test("should display the release announcement process", async ({ page, app, util }) => { + // The TAC release announcement should be displayed + await util.assertReleaseAnnouncementIsVisible("Threads Activity Centre"); + // Hide the release announcement + await util.markReleaseAnnouncementAsRead("Threads Activity Centre"); + await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre"); + + await page.reload(); + // Wait for EW to load + await expect(page.getByRole("navigation", { name: "Spaces" })).toBeVisible(); + // Check that once the release announcement has been marked as viewed, it does not appear again + await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre"); + }); +}); diff --git a/playwright/e2e/right-panel/file-panel.spec.ts b/playwright/e2e/right-panel/file-panel.spec.ts new file mode 100644 index 00000000000..84e7614e8ef --- /dev/null +++ b/playwright/e2e/right-panel/file-panel.spec.ts @@ -0,0 +1,223 @@ +/* +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 { Download, type Page } from "@playwright/test"; + +import { test, expect } from "../../element-web-test"; +import { viewRoomSummaryByName } from "./utils"; + +const ROOM_NAME = "Test room"; +const NAME = "Alice"; + +async function uploadFile(page: Page, file: string) { + // Upload a file from the message composer + await page.locator(".mx_MessageComposer_actions input[type='file']").setInputFiles(file); + + 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(); +} + +test.describe("FilePanel", () => { + test.use({ + displayName: NAME, + }); + + test.beforeEach(async ({ page, user, app }) => { + await app.client.createRoom({ name: ROOM_NAME }); + + // Open the file panel + await viewRoomSummaryByName(page, app, ROOM_NAME); + await page.getByRole("menuitem", { name: "Files" }).click(); + await expect(page.locator(".mx_FilePanel")).toBeVisible(); + }); + + test.describe("render", () => { + test("should render empty state", async ({ page }) => { + // Wait until the information about the empty state is rendered + await expect(page.locator(".mx_FilePanel_empty")).toBeVisible(); + + // Take a snapshot of RightPanel - fix https://github.com/vector-im/element-web/issues/25332 + await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png"); + }); + + test("should list tiles on the panel", async ({ page }) => { + // Upload multiple files + 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 + await expect(roomViewBody.locator(".mx_EventTile[data-layout='group']")).toHaveCount(3); + + // Assert that the image exists and has the alt string + await expect(roomViewBody.locator(".mx_EventTile[data-layout='group'] img[alt='riot.png']")).toBeVisible(); + + // Assert that the audio player is rendered + await expect( + roomViewBody.locator(".mx_EventTile[data-layout='group'] .mx_AudioPlayer_container"), + ).toBeVisible(); + + // Assert that the file button exists + await expect( + roomViewBody.locator(".mx_EventTile_last[data-layout='group'] .mx_MFileBody", { hasText: ".json" }), + ).toBeVisible(); + + const filePanel = page.locator(".mx_FilePanel"); + // Assert that the file panel is opened inside mx_RightPanel and visible + await expect(filePanel).toBeVisible(); + + const filePanelMessageList = filePanel.locator(".mx_RoomView_MessageList"); + + // Assert that data-layout attribute is not applied to file tiles on the panel + await expect(filePanelMessageList.locator(".mx_EventTile[data-layout]")).not.toBeVisible(); + + // Assert that all of the file tiles are rendered + await expect(filePanelMessageList.locator(".mx_EventTile")).toHaveCount(3); + + // Assert that the download links are rendered + await expect(filePanelMessageList.locator(".mx_MFileBody_download")).toHaveCount(3); + + // Assert that the sender of the files is rendered on all of the tiles + await expect(filePanelMessageList.getByText(NAME)).toHaveCount(3); + + // Detect the image file + const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody"); + // Assert that the image is specified as thumbnail and has the alt string + await expect(image.locator("img[class='mx_MImageBody_thumbnail']")).toBeVisible(); + await expect(image.locator("img[alt='riot.png']")).toBeVisible(); + + // Detect the audio file + const audio = filePanelMessageList.locator( + ".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container", + ); + // Assert that the play button is rendered + await expect(audio.getByRole("button", { name: "Play" })).toBeVisible(); + + // Detect the JSON file + // Assert that the tile is rendered as a button + const file = filePanelMessageList.locator( + ".mx_EventTile_mediaLine .mx_MFileBody .mx_MFileBody_info[role='button'] .mx_MFileBody_info_filename", + ); + // Assert that the file name is rendered inside the button with ellipsis + await expect(file.getByText(/matrix.*?\.json/)).toBeVisible(); + + // Make the viewport tall enough to display all of the file tiles on FilePanel + await page.setViewportSize({ width: 800, height: 1000 }); + + // In case the panel is scrollable on the resized viewport + // Assert that the value for flexbox is applied + await expect(filePanel.locator(".mx_ScrollPanel .mx_RoomView_MessageList")).toHaveCSS( + "justify-content", + "flex-end", + ); + // Assert that all of the file tiles are visible before taking a snapshot + await expect(filePanelMessageList.locator(".mx_MImageBody")).toBeVisible(); // top + await expect(filePanelMessageList.locator(".mx_MAudioBody")).toBeVisible(); // middle + const senderDetails = filePanelMessageList.locator(".mx_EventTile_last .mx_EventTile_senderDetails"); + await expect(senderDetails.locator(".mx_DisambiguatedProfile")).toBeVisible(); + await expect(senderDetails.locator(".mx_MessageTimestamp")).toBeVisible(); + + // Take a snapshot of file tiles list on FilePanel + await expect(filePanelMessageList).toMatchScreenshot("file-tiles-list.png", { + // Exclude timestamps & flaky seek bar from snapshot + mask: [page.locator(".mx_MessageTimestamp, .mx_AudioPlayer_seek")], + }); + }); + + test("should render the audio player and play the audio file on the panel", async ({ page }) => { + // Upload an image file + 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", + ); + // Assert that the audio player is rendered + // Assert that the audio file information is rendered + const mediaInfo = audioBody.locator(".mx_AudioPlayer_mediaInfo"); + await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName").getByText("1sec.ogg")).toBeVisible(); + await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible(); + await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size + + // Assert that the duration counter is 00:01 before clicking the play button + await expect(audioBody.locator(".mx_AudioPlayer_mediaInfo time", { hasText: "00:01" })).toBeVisible(); + + // Assert that the counter is zero before clicking the play button + await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + + // Click the play button + await audioBody.getByRole("button", { name: "Play" }).click(); + + // Assert that the pause button is rendered + await expect(audioBody.getByRole("button", { name: "Pause" })).toBeVisible(); + + // Assert that the timer is reset when the audio file finished playing + await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + + // Assert that the play button is rendered + await expect(audioBody.getByRole("button", { name: "Play" })).toBeVisible(); + }); + + test("should render file size in kibibytes on a file tile", async ({ page }) => { + const size = "1.12 KB"; // actual file size in kibibytes (1024 bytes) + + // Upload a file + 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) + // See: https://github.com/vector-im/element-web/issues/24866 + await expect(tile.locator(".mx_MFileBody_info_filename", { hasText: size })).toBeVisible(); + await expect(tile.locator(".mx_MFileBody_download a", { hasText: size })).toBeVisible(); + await expect(tile.locator(".mx_MFileBody_download .mx_MImageBody_size", { hasText: size })).toBeVisible(); + }); + }); + + test.describe("download", () => { + test("should download an image via the link on the panel", async ({ page, context }) => { + // Upload an image file + await uploadFile(page, "playwright/sample-files/riot.png"); + + // Detect the image file on the panel + const imageBody = page.locator( + ".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody", + ); + + const link = imageBody.locator(".mx_MFileBody_download a"); + + const newPagePromise = context.waitForEvent("page"); + + const downloadPromise = new Promise((resolve) => { + page.once("download", resolve); + }); + + // Click the anchor link (not the image itself) + await link.click(); + + const newPage = await newPagePromise; + // XXX: Clicking the link opens the image in a new tab on some browsers rather than downloading + await expect(newPage) + .toHaveURL(/.+\/_matrix\/media\/\w+\/download\/localhost\/\w+/) + .catch(async () => { + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("riot.png"); + }); + }); + }); +}); diff --git a/playwright/e2e/right-panel/notification-panel.spec.ts b/playwright/e2e/right-panel/notification-panel.spec.ts new file mode 100644 index 00000000000..6223c1c13f7 --- /dev/null +++ b/playwright/e2e/right-panel/notification-panel.spec.ts @@ -0,0 +1,43 @@ +/* +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 { test, expect } from "../../element-web-test"; + +const ROOM_NAME = "Test room"; +const NAME = "Alice"; + +test.describe("NotificationPanel", () => { + test.use({ + displayName: NAME, + labsFlags: ["feature_notifications"], + }); + + test.beforeEach(async ({ app, user }) => { + await app.client.createRoom({ name: ROOM_NAME }); + }); + + test("should render empty state", async ({ page, app }) => { + await app.viewRoomByName(ROOM_NAME); + + await page.getByRole("button", { name: "Notifications" }).click(); + + // Wait until the information about the empty state is rendered + await expect(page.locator(".mx_NotificationPanel_empty")).toBeVisible(); + + // Take a snapshot of RightPanel + await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png"); + }); +}); diff --git a/playwright/e2e/right-panel/right-panel.spec.ts b/playwright/e2e/right-panel/right-panel.spec.ts new file mode 100644 index 00000000000..4f578748d6e --- /dev/null +++ b/playwright/e2e/right-panel/right-panel.spec.ts @@ -0,0 +1,152 @@ +/* +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 { Locator, type Page } from "@playwright/test"; + +import { test, expect } from "../../element-web-test"; +import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils"; + +const ROOM_NAME = "Test room"; +const ROOM_NAME_LONG = + "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 ut " + + "aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum " + + "dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui " + + "officia deserunt mollit anim id est laborum."; +const SPACE_NAME = "Test space"; +const NAME = "Alice"; +const ROOM_ADDRESS_LONG = + "loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua"; + +function getMemberTileByName(page: Page, name: string): Locator { + return page.locator(`.mx_EntityTile, [title="${name}"]`); +} + +test.describe("RightPanel", () => { + test.use({ + displayName: NAME, + }); + + test.beforeEach(async ({ app, user }) => { + await app.client.createRoom({ name: ROOM_NAME }); + await app.client.createSpace({ name: SPACE_NAME }); + }); + + test.describe("in rooms", () => { + test("should handle long room address and long room name", async ({ page, app }) => { + await app.client.createRoom({ name: ROOM_NAME_LONG }); + await viewRoomSummaryByName(page, app, ROOM_NAME_LONG); + + await app.settings.openRoomSettings(); + + // Set a local room address + const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" }); + await localAddresses.getByRole("textbox").fill(ROOM_ADDRESS_LONG); + await localAddresses.getByRole("button", { name: "Add" }).click(); + await expect(localAddresses.getByText(`#${ROOM_ADDRESS_LONG}:localhost`)).toHaveClass( + "mx_EditableItem_item", + ); + + await app.closeDialog(); + + // Close and reopen the right panel to render the room address + await page.getByRole("button", { name: "Room info" }).click(); + await expect(page.locator(".mx_RightPanel")).not.toBeVisible(); + await page.getByRole("button", { name: "Room info" }).click(); + + await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png"); + }); + + test("should handle clicking add widgets", async ({ page, app }) => { + await viewRoomSummaryByName(page, app, ROOM_NAME); + + await page.getByRole("button", { name: "Add widgets, bridges & bots" }).click(); + await expect(page.locator(".mx_IntegrationManager")).toBeVisible(); + }); + + test("should handle viewing export chat", async ({ page, app }) => { + await viewRoomSummaryByName(page, app, ROOM_NAME); + + await page.getByRole("menuitem", { name: "Export Chat" }).click(); + await expect(page.locator(".mx_ExportDialog")).toBeVisible(); + }); + + test("should handle viewing share room", async ({ page, app }) => { + await viewRoomSummaryByName(page, app, ROOM_NAME); + + await page.getByRole("menuitem", { name: "Copy link" }).click(); + await expect(page.locator(".mx_ShareDialog")).toBeVisible(); + }); + + test("should handle viewing room settings", async ({ page, app }) => { + await viewRoomSummaryByName(page, app, ROOM_NAME); + + await page.getByRole("menuitem", { name: "Settings" }).click(); + await expect(page.locator(".mx_RoomSettingsDialog")).toBeVisible(); + await expect(page.locator(".mx_Dialog_title").getByText("Room Settings - " + ROOM_NAME)).toBeVisible(); + }); + + test("should handle viewing files", async ({ page, app }) => { + await viewRoomSummaryByName(page, app, ROOM_NAME); + + await page.getByRole("menuitem", { name: "Files" }).click(); + await expect(page.locator(".mx_FilePanel")).toBeVisible(); + await expect(page.locator(".mx_FilePanel_empty")).toBeVisible(); + + await page.getByRole("button", { name: "Room information" }).click(); + await checkRoomSummaryCard(page, ROOM_NAME); + }); + + test("should handle viewing room member", async ({ page, app }) => { + await viewRoomSummaryByName(page, app, ROOM_NAME); + + await page.getByRole("menuitem", { name: "People" }).click(); + await expect(page.locator(".mx_MemberList")).toBeVisible(); + + await getMemberTileByName(page, NAME).click(); + await expect(page.locator(".mx_UserInfo")).toBeVisible(); + await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible(); + + await page.getByRole("button", { name: "Room members" }).click(); + await expect(page.locator(".mx_MemberList")).toBeVisible(); + + await page.getByRole("button", { name: "Room information" }).click(); + await checkRoomSummaryCard(page, ROOM_NAME); + }); + }); + + test.describe("in spaces", () => { + test("should handle viewing space member", async ({ page, app }) => { + await app.viewSpaceHomeByName(SPACE_NAME); + + // \d represents the number of the space members + await page + .locator(".mx_RoomInfoLine_private") + .getByRole("button", { name: /\d member/ }) + .click(); + await expect(page.locator(".mx_MemberList")).toBeVisible(); + await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible(); + + await getMemberTileByName(page, NAME).click(); + await expect(page.locator(".mx_UserInfo")).toBeVisible(); + await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible(); + await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible(); + + await page.getByRole("button", { name: "Back" }).click(); + await expect(page.locator(".mx_MemberList")).toBeVisible(); + }); + }); +}); diff --git a/playwright/e2e/right-panel/utils.ts b/playwright/e2e/right-panel/utils.ts new file mode 100644 index 00000000000..a8dac8394d0 --- /dev/null +++ b/playwright/e2e/right-panel/utils.ts @@ -0,0 +1,30 @@ +/* +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, expect } from "@playwright/test"; + +import { ElementAppPage } from "../../pages/ElementAppPage"; + +export async function viewRoomSummaryByName(page: Page, app: ElementAppPage, name: string): Promise { + await app.viewRoomByName(name); + await page.getByRole("button", { name: "Room info" }).click(); + return checkRoomSummaryCard(page, name); +} + +export async function checkRoomSummaryCard(page: Page, name: string): Promise { + await expect(page.locator(".mx_RoomSummaryCard")).toBeVisible(); + await expect(page.locator(".mx_RoomSummaryCard")).toContainText(name); +} diff --git a/playwright/e2e/room-directory/room-directory.spec.ts b/playwright/e2e/room-directory/room-directory.spec.ts new file mode 100644 index 00000000000..5068f8e5cc0 --- /dev/null +++ b/playwright/e2e/room-directory/room-directory.spec.ts @@ -0,0 +1,88 @@ +/* +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 { Preset, Visibility } from "matrix-js-sdk/src/matrix"; +import { test, expect } from "../../element-web-test"; + +test.describe("Room Directory", () => { + test.use({ + displayName: "Ray", + botCreateOpts: { displayName: "Paul" }, + }); + + test("should allow admin to add alias & publish room to directory", async ({ page, app, user, bot }) => { + const roomId = await app.client.createRoom({ + name: "Gaming", + preset: "public_chat" as Preset, + }); + + await app.viewRoomByName("Gaming"); + await app.settings.openRoomSettings(); + + // First add a local address `gaming` + const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" }); + await localAddresses.getByRole("textbox").fill("gaming"); + await localAddresses.getByRole("button", { name: "Add" }).click(); + await expect(localAddresses.getByText("#gaming:localhost")).toHaveClass("mx_EditableItem_item"); + + // Publish into the public rooms directory + const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" }); + await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue("#gaming:localhost"); + const checkbox = publishedAddresses + .locator(".mx_SettingsFlag", { hasText: "Publish this room to the public in localhost's room directory?" }) + .getByRole("switch"); + await checkbox.check(); + await expect(checkbox).toBeChecked(); + + await app.closeDialog(); + + const resp = await bot.publicRooms({}); + expect(resp.total_room_count_estimate).toEqual(1); + expect(resp.chunk).toHaveLength(1); + expect(resp.chunk[0].room_id).toEqual(roomId); + }); + + test("should allow finding published rooms in directory", async ({ page, app, user, bot }) => { + const name = "This is a public room"; + await bot.createRoom({ + visibility: "public" as Visibility, + name, + room_alias_name: "test1234", + }); + + await page.getByRole("button", { name: "Explore rooms" }).click(); + + const dialog = page.locator(".mx_SpotlightDialog"); + await dialog.getByRole("textbox", { name: "Search" }).fill("Unknown Room"); + await expect( + dialog.getByText("If you can't find the room you're looking for, ask for an invite or create a new room."), + ).toHaveClass("mx_SpotlightDialog_otherSearches_messageSearchText"); + + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("filtered-no-results.png"); + + await dialog.getByRole("textbox", { name: "Search" }).fill("test1234"); + await expect(dialog.getByText(name)).toHaveClass("mx_SpotlightDialog_result_publicRoomName"); + + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("filtered-one-result.png"); + + await page + .locator(".mx_SpotlightDialog .mx_SpotlightDialog_option") + .getByRole("button", { name: "Join" }) + .click(); + + await expect(page).toHaveURL("/#/room/#test1234:localhost"); + }); +}); diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts new file mode 100644 index 00000000000..4008517d093 --- /dev/null +++ b/playwright/e2e/room/room-header.spec.ts @@ -0,0 +1,301 @@ +/* +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 { Page } from "@playwright/test"; + +import { test, expect } from "../../element-web-test"; +import { ElementAppPage } from "../../pages/ElementAppPage"; +import type { Container } from "../../../src/stores/widgets/types"; + +test.describe("Room Header", () => { + test.use({ + displayName: "Sakura", + }); + + test.describe("with feature_notifications enabled", () => { + 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"); + + const header = page.locator(".mx_LegacyRoomHeader"); + // Names (aria-label) of every button rendered on mx_LegacyRoomHeader by default + const expectedButtonNames = [ + "Room options", // The room name button next to the room avatar, which renders dropdown menu on click + "Voice call", + "Video call", + "Search", + "Threads", + "Notifications", + "Room info", + ]; + + // Assert they are found and visible + for (const name of expectedButtonNames) { + await expect(header.getByRole("button", { name })).toBeVisible(); + } + + // Assert that just those seven buttons exist on mx_LegacyRoomHeader by default + await expect(header.getByRole("button")).toHaveCount(7); + + await expect(header).toMatchScreenshot("room-header.png"); + }); + + test("should render a very long room name without collapsing the buttons", async ({ page, app, user }) => { + const LONG_ROOM_NAME = + "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 ut " + + "aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum " + + "dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui " + + "officia deserunt mollit anim id est laborum."; + + await app.client.createRoom({ name: LONG_ROOM_NAME }); + await app.viewRoomByName(LONG_ROOM_NAME); + + const header = page.locator(".mx_LegacyRoomHeader"); + // Wait until the room name is set + await expect(page.locator(".mx_LegacyRoomHeader_nametext").getByText(LONG_ROOM_NAME)).toBeVisible(); + + // Assert the size of buttons on RoomHeader are specified and the buttons are not compressed + // Note these assertions do not check the size of mx_LegacyRoomHeader_name button + const buttons = page.locator(".mx_LegacyRoomHeader_button"); + await expect(buttons).toHaveCount(6); + for (const button of await buttons.all()) { + await expect(button).toBeVisible(); + await expect(button).toHaveCSS("height", "32px"); + await expect(button).toHaveCSS("width", "32px"); + } + + await expect(header).toMatchScreenshot("room-header-long-name.png"); + }); + + test("should have buttons highlighted by being clicked", async ({ page, app, user }) => { + await app.client.createRoom({ name: "Test Room" }); + await app.viewRoomByName("Test Room"); + + const header = page.locator(".mx_LegacyRoomHeader"); + // Check these buttons + const buttonsHighlighted = ["Threads", "Notifications", "Room info"]; + + for (const name of buttonsHighlighted) { + await header.getByRole("button", { name: name }).click(); // Highlight the button + } + + await expect(header).toMatchScreenshot("room-header-highlighted.png"); + }); + }); + + test.describe("with feature_pinning enabled", () => { + 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" }); + await app.viewRoomByName("Test Room"); + + const composer = app.getComposer().locator("[contenteditable]"); + await composer.fill("Test message"); + await composer.press("Enter"); + + const lastTile = page.locator(".mx_EventTile_last"); + await lastTile.hover(); + await lastTile.getByRole("button", { name: "Options" }).click(); + + await page.getByRole("menuitem", { name: "Pin" }).click(); + + await expect( + page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Pinned messages" }), + ).toBeVisible(); + }); + }); + + test.describe("with a video room", () => { + 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(); + + await page.getByRole("menuitem", { name: "New video room" }).click(); + + await page.getByRole("textbox", { name: "Name" }).type("Test video room"); + + await page.getByRole("button", { name: "Create video room" }).click(); + + await app.viewRoomByName("Test video room"); + }; + + 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 ({ + page, + app, + user, + }) => { + await createVideoRoom(page, app); + + await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click(); + + // Assert that the call view is still visible + await expect(page.locator(".mx_CallView")).toBeVisible(); + + // Assert that GELS is visible + await expect( + page.locator(".mx_RightPanel .mx_TimelineCard").getByText("Sakura created and configured the room."), + ).toBeVisible(); + }); + }); + + test.describe("with a widget", () => { + const ROOM_NAME = "Test Room with a widget"; + const WIDGET_ID = "fake-widget"; + const WIDGET_HTML = ` + + + Fake Widget + + + Hello World + + + `; + + test.beforeEach(async ({ page, app, user, webserver }) => { + const widgetUrl = webserver.start(WIDGET_HTML); + const roomId = await app.client.createRoom({ name: ROOM_NAME }); + + // setup widget via state event + await app.client.evaluate( + async (matrixClient, { roomId, widgetUrl, id }) => { + await matrixClient.sendStateEvent( + roomId, + "im.vector.modular.widgets", + { + id, + creatorUserId: "somebody", + type: "widget", + name: "widget", + url: widgetUrl, + }, + id, + ); + await matrixClient.sendStateEvent( + roomId, + "io.element.widgets.layout", + { + widgets: { + [id]: { + container: "top" as Container, + index: 1, + width: 100, + height: 0, + }, + }, + }, + "", + ); + }, + { + roomId, + widgetUrl, + id: WIDGET_ID, + }, + ); + + // open the room + await app.viewRoomByName(ROOM_NAME); + }); + + test("should highlight the apps button", async ({ page, app, user }) => { + // Assert that AppsDrawer is rendered + await expect(page.locator(".mx_AppsDrawer")).toBeVisible(); + + const header = page.locator(".mx_LegacyRoomHeader"); + // Assert that "Hide Widgets" button is rendered and aria-checked is set to true + await expect(header.getByRole("button", { name: "Hide Widgets" })).toHaveAttribute("aria-checked", "true"); + + await expect(header).toMatchScreenshot("room-header-with-apps-button-highlighted.png"); + }); + + test("should support hiding a widget", async ({ page, app, user }) => { + await expect(page.locator(".mx_AppsDrawer")).toBeVisible(); + + const header = page.locator(".mx_LegacyRoomHeader"); + // Click the apps button to hide AppsDrawer + await header.getByRole("button", { name: "Hide Widgets" }).click(); + + // Assert that "Show widgets" button is rendered and aria-checked is set to false + await expect(header.getByRole("button", { name: "Show Widgets" })).toHaveAttribute("aria-checked", "false"); + + // Assert that AppsDrawer is not rendered + await expect(page.locator(".mx_AppsDrawer")).not.toBeVisible(); + + await expect(header).toMatchScreenshot("room-header-with-apps-button-not-highlighted.png"); + }); + }); + + test.describe("with encryption", () => { + test("should render the E2E icon and the buttons", async ({ page, app, user }) => { + // Create an encrypted room + await app.client.createRoom({ + name: "Test Encrypted Room", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); + await app.viewRoomByName("Test Encrypted Room"); + + const header = page.locator(".mx_LegacyRoomHeader"); + await expect(header).toMatchScreenshot("encrypted-room-header.png"); + }); + }); +}); diff --git a/playwright/e2e/room/room.spec.ts b/playwright/e2e/room/room.spec.ts new file mode 100644 index 00000000000..5b60e6f3bbe --- /dev/null +++ b/playwright/e2e/room/room.spec.ts @@ -0,0 +1,106 @@ +/* +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 { EventType } from "matrix-js-sdk/src/matrix"; +import { test, expect } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; + +test.describe("Room Directory", () => { + test.use({ + displayName: "Alice", + }); + + test("should switch between existing dm rooms without a loader", async ({ page, homeserver, app, user }) => { + const bob = new Bot(page, homeserver, { displayName: "Bob" }); + await bob.prepareClient(); + const charlie = new Bot(page, homeserver, { displayName: "Charlie" }); + await charlie.prepareClient(); + + // create dms with bob and charlie + await app.client.evaluate( + async (cli, { bob, charlie }) => { + const bobRoom = await cli.createRoom({ is_direct: true }); + const charlieRoom = await cli.createRoom({ is_direct: true }); + await cli.invite(bobRoom.room_id, bob); + await cli.invite(charlieRoom.room_id, charlie); + await cli.setAccountData("m.direct" as EventType, { + [bob]: [bobRoom.room_id], + [charlie]: [charlieRoom.room_id], + }); + }, + { + bob: bob.credentials.userId, + charlie: charlie.credentials.userId, + }, + ); + + await app.viewRoomByName("Bob"); + + // short timeout because loader is only visible for short period + // we want to make sure it is never displayed when switching these rooms + await expect(page.locator(".mx_RoomPreviewBar_spinnerTitle")).not.toBeVisible({ timeout: 1 }); + // confirm the room was loaded + await expect(page.getByText("Bob joined the room")).toBeVisible(); + + await app.viewRoomByName("Charlie"); + await expect(page.locator(".mx_RoomPreviewBar_spinnerTitle")).not.toBeVisible({ timeout: 1 }); + // 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); + + // Let the app settle to avoid flakiness + await page.waitForTimeout(500); + + // 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/room_options/marked_unread.spec.ts b/playwright/e2e/room_options/marked_unread.spec.ts new file mode 100644 index 00000000000..799acf22500 --- /dev/null +++ b/playwright/e2e/room_options/marked_unread.spec.ts @@ -0,0 +1,61 @@ +/* +Copyright 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. +*/ + +import { test, expect } from "../../element-web-test"; + +const TEST_ROOM_NAME = "The mark unread test room"; + +test.describe("Mark as Unread", () => { + test.use({ + displayName: "Tom", + botCreateOpts: { + displayName: "BotBob", + autoAcceptInvites: true, + }, + }); + + test("should mark a room as unread", async ({ page, app, bot }) => { + const roomId = await app.client.createRoom({ + name: TEST_ROOM_NAME, + }); + const dummyRoomId = await app.client.createRoom({ + name: "Room of no consequence", + }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + await bot.sendMessage(roomId, "I am a robot. Beep."); + + // Regular notification on new message + await expect(page.getByLabel(TEST_ROOM_NAME + " 1 unread message.")).toBeVisible(); + await expect(page).toHaveTitle("Element [1]"); + + await page.goto("/#/room/" + roomId); + + // should now be read, since we viewed the room (we have to assert the page title: + // the room badge isn't visible since we're viewing the room) + await expect(page).toHaveTitle("Element | " + TEST_ROOM_NAME); + + // navigate away from the room again + await page.goto("/#/room/" + dummyRoomId); + + const roomTile = page.getByLabel(TEST_ROOM_NAME); + await roomTile.focus(); + await roomTile.getByRole("button", { name: "Room options" }).click(); + await page.getByRole("menuitem", { name: "Mark as unread" }).click(); + + expect(page.getByLabel(TEST_ROOM_NAME + " Unread messages.")).toBeVisible(); + }); +}); diff --git a/playwright/e2e/settings/appearance-user-settings-tab.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab.spec.ts new file mode 100644 index 00000000000..7e16d739558 --- /dev/null +++ b/playwright/e2e/settings/appearance-user-settings-tab.spec.ts @@ -0,0 +1,219 @@ +/* +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 { test, expect } from "../../element-web-test"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; + +test.describe("Appearance user settings tab", () => { + test.use({ + displayName: "Hanako", + }); + + test("should be rendered properly", async ({ page, user, app }) => { + const tab = await app.settings.openUserSettings("Appearance"); + + // Click "Show advanced" link button + await tab.getByRole("button", { name: "Show advanced" }).click(); + + // Assert that "Hide advanced" link button is rendered + await expect(tab.getByRole("button", { name: "Hide advanced" })).toBeVisible(); + + await expect(tab).toMatchScreenshot("appearance-tab.png"); + }); + + test("should support switching layouts", async ({ page, user, app }) => { + // Create and view a room first + await app.client.createRoom({ name: "Test Room" }); + await app.viewRoomByName("Test Room"); + + await app.settings.openUserSettings("Appearance"); + + const buttons = page.locator(".mx_LayoutSwitcher_RadioButton"); + + // Assert that the layout selected by default is "Modern" + await expect( + buttons.locator(".mx_StyledRadioButton_enabled", { + hasText: "Modern", + }), + ).toBeVisible(); + + // Assert that the room layout is set to group (modern) layout + await expect(page.locator(".mx_RoomView_body[data-layout='group']")).toBeVisible(); + + // Select the first layout + await buttons.first().click(); + // Assert that the layout selected is "IRC (Experimental)" + await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible(); + + // Assert that the room layout is set to IRC layout + await expect(page.locator(".mx_RoomView_body[data-layout='irc']")).toBeVisible(); + + // Select the last layout + await buttons.last().click(); + + // Assert that the layout selected is "Message bubbles" + await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible(); + + // Assert that the room layout is set to bubble layout + await expect(page.locator(".mx_RoomView_body[data-layout='bubble']")).toBeVisible(); + }); + + test("should support changing font size by using the font size dropdown", async ({ page, app, user }) => { + await app.settings.openUserSettings("Appearance"); + + const tab = page.getByTestId("mx_AppearanceUserSettingsTab"); + const fontDropdown = tab.locator(".mx_FontScalingPanel_Dropdown"); + await expect(fontDropdown.getByLabel("Font size")).toBeVisible(); + + // Default browser font size is 16px and the select value is 0 + // -4 value is 12px + await fontDropdown.getByLabel("Font size").selectOption({ value: "-4" }); + + await expect(page).toMatchScreenshot("window-12px.png"); + }); + + test("should support enabling compact group (modern) layout", async ({ page, app, user }) => { + // Create and view a room first + await app.client.createRoom({ name: "Test Room" }); + await app.viewRoomByName("Test Room"); + + await app.settings.openUserSettings("Appearance"); + + // Click "Show advanced" link button + const tab = page.getByTestId("mx_AppearanceUserSettingsTab"); + await tab.getByRole("button", { name: "Show advanced" }).click(); + + await tab.locator("label", { hasText: "Use a more compact 'Modern' layout" }).click(); + + // Assert that the room layout is set to compact group (modern) layout + await expect(page.locator("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout")).toBeVisible(); + }); + + test("should disable compact group (modern) layout option on IRC layout and bubble layout", async ({ + page, + app, + user, + }) => { + await app.settings.openUserSettings("Appearance"); + const tab = page.getByTestId("mx_AppearanceUserSettingsTab"); + + const checkDisabled = async () => { + await expect(tab.getByRole("checkbox", { name: "Use a more compact 'Modern' layout" })).toBeDisabled(); + }; + + // Click "Show advanced" link button + await tab.getByRole("button", { name: "Show advanced" }).click(); + + const buttons = page.locator(".mx_LayoutSwitcher_RadioButton"); + + // Enable IRC layout + await buttons.first().click(); + + // Assert that the layout selected is "IRC (Experimental)" + await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible(); + + await checkDisabled(); + + // Enable bubble layout + await buttons.last().click(); + + // Assert that the layout selected is "IRC (Experimental)" + await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible(); + + await checkDisabled(); + }); + + test("should support enabling system font", async ({ page, app, user }) => { + await app.settings.openUserSettings("Appearance"); + const tab = page.getByTestId("mx_AppearanceUserSettingsTab"); + + // Click "Show advanced" link button + await tab.getByRole("button", { name: "Show advanced" }).click(); + + await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click(); + await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click(); + + // Assert that the font-family value was removed + await expect(page.locator("body")).toHaveCSS("font-family", '""'); + }); + + test.describe("Theme Choice Panel", () => { + test.beforeEach(async ({ app, user }) => { + // Disable the default theme for consistency in case ThemeWatcher automatically chooses it + await app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false); + }); + + test("should be rendered with the light theme selected", async ({ page, app }) => { + await app.settings.openUserSettings("Appearance"); + const themePanel = page.getByTestId("mx_ThemeChoicePanel"); + + const useSystemTheme = themePanel.getByTestId("checkbox-use-system-theme"); + await expect(useSystemTheme.getByText("Match system theme")).toBeVisible(); + // Assert that 'Match system theme' is not checked + // Note that mx_Checkbox_checkmark exists and is hidden by CSS if it is not checked + await expect(useSystemTheme.locator(".mx_Checkbox_checkmark")).not.toBeVisible(); + + const selectors = themePanel.getByTestId("theme-choice-panel-selectors"); + await expect(selectors.locator(".mx_ThemeSelector_light")).toBeVisible(); + await expect(selectors.locator(".mx_ThemeSelector_dark")).toBeVisible(); + // Assert that the light theme is selected + await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_enabled")).toBeVisible(); + // Assert that the buttons for the light and dark theme are not enabled + await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).not.toBeVisible(); + await expect(selectors.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).not.toBeVisible(); + + // Assert that the checkbox for the high contrast theme is rendered + await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible(); + }); + + test("should disable the labels for themes and the checkbox for the high contrast theme if the checkbox for the system theme is clicked", async ({ + page, + app, + }) => { + await app.settings.openUserSettings("Appearance"); + const themePanel = page.getByTestId("mx_ThemeChoicePanel"); + + await themePanel.locator(".mx_Checkbox", { hasText: "Match system theme" }).click(); + + // Assert that the labels for the light theme and dark theme are disabled + await expect(themePanel.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).toBeVisible(); + await expect(themePanel.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).toBeVisible(); + + // Assert that there does not exist a label for an enabled theme + await expect(themePanel.locator("label.mx_StyledRadioButton_enabled")).not.toBeVisible(); + + // Assert that the checkbox and label to enable the high contrast theme should not exist + await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible(); + }); + + test("should not render the checkbox and the label for the high contrast theme if the dark theme is selected", async ({ + page, + app, + }) => { + await app.settings.openUserSettings("Appearance"); + const themePanel = page.getByTestId("mx_ThemeChoicePanel"); + + // Assert that the checkbox and the label to enable the high contrast theme should exist + await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible(); + + // Enable the dark theme + await themePanel.locator(".mx_ThemeSelector_dark").click(); + + // Assert that the checkbox and the label should not exist + await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible(); + }); + }); +}); diff --git a/playwright/e2e/settings/device-management.spec.ts b/playwright/e2e/settings/device-management.spec.ts new file mode 100644 index 00000000000..b4595610b82 --- /dev/null +++ b/playwright/e2e/settings/device-management.spec.ts @@ -0,0 +1,105 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Device manager", () => { + test.use({ + displayName: "Alice", + }); + + test.beforeEach(async ({ homeserver, user }) => { + // create 3 extra sessions to manage + for (let i = 0; i < 3; i++) { + await homeserver.loginUser(user.userId, user.password); + } + }); + + test("should display sessions", async ({ page, app }) => { + await app.settings.openUserSettings("Sessions"); + const tab = page.locator(".mx_SettingsTab"); + + await expect(tab.getByText("Current session", { exact: true })).toBeVisible(); + + const currentSessionSection = tab.getByTestId("current-session-section"); + await expect(currentSessionSection.getByText("Unverified session")).toBeVisible(); + + // current session details opened + await currentSessionSection.getByRole("button", { name: "Show details" }).click(); + await expect(currentSessionSection.getByText("Session details")).toBeVisible(); + + // close current session details + await currentSessionSection.getByRole("button", { name: "Hide details" }).click(); + await expect(currentSessionSection.getByText("Session details")).not.toBeVisible(); + + const securityRecommendationsSection = tab.getByTestId("security-recommendations-section"); + await expect(securityRecommendationsSection.getByText("Security recommendations")).toBeVisible(); + await securityRecommendationsSection.getByRole("button", { name: "View all (3)" }).click(); + + /** + * Other sessions section + */ + await expect(tab.getByText("Other sessions")).toBeVisible(); + // filter applied after clicking through from security recommendations + await expect(tab.getByLabel("Filter devices")).toHaveText("Show: Unverified"); + const filteredDeviceListItems = tab.locator(".mx_FilteredDeviceList_listItem"); + await expect(filteredDeviceListItems).toHaveCount(3); + + // select two sessions + // force click as the input element itself is not visible (its size is zero) + await filteredDeviceListItems.first().click({ force: true }); + await filteredDeviceListItems.last().click({ force: true }); + + // sign out from list selection action buttons + await tab.getByRole("button", { name: "Sign out", exact: true }).click(); + await page.getByRole("dialog").getByTestId("dialog-primary-button").click(); + + // list updated after sign out + await expect(filteredDeviceListItems).toHaveCount(1); + // security recommendation count updated + await expect(tab.getByRole("button", { name: "View all (1)" })).toBeVisible(); + + const sessionName = `Alice's device`; + // open the first session + const firstSession = filteredDeviceListItems.first(); + await firstSession.getByRole("button", { name: "Show details" }).click(); + + await expect(firstSession.getByText("Session details")).toBeVisible(); + + await firstSession.getByRole("button", { name: "Rename" }).click(); + await firstSession.getByTestId("device-rename-input").type(sessionName); + await firstSession.getByRole("button", { name: "Save" }).click(); + // there should be a spinner while device updates + await expect(firstSession.locator(".mx_Spinner")).toBeVisible(); + // wait for spinner to complete + await expect(firstSession.locator(".mx_Spinner")).not.toBeVisible(); + + // session name updated in details + await expect(firstSession.locator(".mx_DeviceDetailHeading h4").getByText(sessionName)).toBeVisible(); + // and main list item + await expect(firstSession.locator(".mx_DeviceTile h4").getByText(sessionName)).toBeVisible(); + + // sign out using the device details sign out + await firstSession.getByRole("button", { name: "Sign out of this session" }).click(); + + // confirm the signout + await page.getByRole("dialog").getByTestId("dialog-primary-button").click(); + + // no other sessions or security recommendations sections when only one session + await expect(tab.getByText("Other sessions")).not.toBeVisible(); + await expect(tab.getByTestId("security-recommendations-section")).not.toBeVisible(); + }); +}); diff --git a/playwright/e2e/settings/general-room-settings-tab.spec.ts b/playwright/e2e/settings/general-room-settings-tab.spec.ts new file mode 100644 index 00000000000..ec3c14b2ca4 --- /dev/null +++ b/playwright/e2e/settings/general-room-settings-tab.spec.ts @@ -0,0 +1,63 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("General room settings tab", () => { + const roomName = "Test Room"; + + test.use({ + displayName: "Hanako", + }); + + test.beforeEach(async ({ user, app }) => { + await app.client.createRoom({ name: roomName }); + await app.viewRoomByName(roomName); + }); + + test("should be rendered properly", async ({ page, app }) => { + const settings = await app.settings.openRoomSettings("General"); + + // Assert that "Show less" details element is rendered + await expect(settings.getByText("Show less")).toBeVisible(); + + await expect(settings).toMatchScreenshot(); + + // Click the "Show less" details element + await settings.getByText("Show less").click(); + + // Assert that "Show more" details element is rendered instead of "Show more" + await expect(settings.getByText("Show less")).not.toBeVisible(); + await expect(settings.getByText("Show more")).toBeVisible(); + }); + + test("long address should not cause dialog to overflow", async ({ page, app }) => { + const settings = await app.settings.openRoomSettings("General"); + // 1. Set the room-address to be a really long string + const longString = "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksd".repeat(4); + await settings.locator("#roomAliases input[label='Room address']").fill(longString); + await settings.locator("#roomAliases").getByText("Add", { exact: true }).click(); + + // 2. wait for the new setting to apply ... + await expect(settings.locator("#canonicalAlias")).toHaveValue(`#${longString}:localhost`); + + // 3. Check if the dialog overflows + const dialogBoundingBox = await page.locator(".mx_Dialog").boundingBox(); + const inputBoundingBox = await settings.locator("#canonicalAlias").boundingBox(); + // Assert that the width of the select element is less than that of .mx_Dialog div. + expect(inputBoundingBox.width).toBeLessThan(dialogBoundingBox.width); + }); +}); diff --git a/playwright/e2e/settings/general-user-settings-tab.spec.ts b/playwright/e2e/settings/general-user-settings-tab.spec.ts new file mode 100644 index 00000000000..41210292a3a --- /dev/null +++ b/playwright/e2e/settings/general-user-settings-tab.spec.ts @@ -0,0 +1,181 @@ +/* +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 { test, expect } from "../../element-web-test"; + +const USER_NAME = "Bob"; +const USER_NAME_NEW = "Alice"; +const IntegrationManager = "scalar.vector.im"; + +test.describe("General user settings tab", () => { + test.use({ + displayName: USER_NAME, + config: { + default_country_code: "US", // For checking the international country calling code + }, + uut: async ({ app, user }, use) => { + const locator = await app.settings.openUserSettings("General"); + await use(locator); + }, + }); + + test("should be rendered properly", async ({ uut, user }) => { + await expect(uut).toMatchScreenshot("general.png"); + + // Assert that the top heading is rendered + await expect(uut.getByRole("heading", { name: "General" })).toBeVisible(); + + const profile = uut.locator(".mx_UserProfileSettings_profile"); + await profile.scrollIntoViewIfNeeded(); + await expect(profile.getByRole("textbox", { name: "Display Name" })).toHaveValue(USER_NAME); + + // Assert that a userId is rendered + expect(uut.getByLabel("Username")).toHaveText(user.userId); + + // Wait until spinners disappear + await expect(uut.getByTestId("accountSection").locator(".mx_Spinner")).not.toBeVisible(); + await expect(uut.getByTestId("discoverySection").locator(".mx_Spinner")).not.toBeVisible(); + + const accountSection = uut.getByTestId("accountSection"); + // Assert that input areas for changing a password exists + const changePassword = accountSection.locator("form.mx_GeneralUserSettingsTab_section--account_changePassword"); + await changePassword.scrollIntoViewIfNeeded(); + await expect(changePassword.getByLabel("Current password")).toBeVisible(); + await expect(changePassword.getByLabel("New Password")).toBeVisible(); + await expect(changePassword.getByLabel("Confirm password")).toBeVisible(); + + // Check email addresses area + const emailAddresses = uut.getByTestId("mx_AccountEmailAddresses"); + await emailAddresses.scrollIntoViewIfNeeded(); + // Assert that an input area for a new email address is rendered + await expect(emailAddresses.getByRole("textbox", { name: "Email Address" })).toBeVisible(); + // Assert the add button is visible + await expect(emailAddresses.getByRole("button", { name: "Add" })).toBeVisible(); + + // Check phone numbers area + const phoneNumbers = uut.getByTestId("mx_AccountPhoneNumbers"); + await phoneNumbers.scrollIntoViewIfNeeded(); + // Assert that an input area for a new phone number is rendered + await expect(phoneNumbers.getByRole("textbox", { name: "Phone Number" })).toBeVisible(); + // Assert that the add button is rendered + await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible(); + + // Check language and region setting dropdown + const languageInput = uut.locator(".mx_GeneralUserSettingsTab_section_languageInput"); + await languageInput.scrollIntoViewIfNeeded(); + // Check the default value + await expect(languageInput.getByText("English")).toBeVisible(); + // Click the button to display the dropdown menu + await languageInput.getByRole("button", { name: "Language Dropdown" }).click(); + // Assert that the default option is rendered and highlighted + languageInput.getByRole("option", { name: /Albanian/ }); + await expect(languageInput.getByRole("option", { name: /Albanian/ })).toHaveClass( + /mx_Dropdown_option_highlight/, + ); + await expect(languageInput.getByRole("option", { name: /Deutsch/ })).toBeVisible(); + // Click again to close the dropdown + await languageInput.getByRole("button", { name: "Language Dropdown" }).click(); + // Assert that the default value is rendered again + await expect(languageInput.getByText("English")).toBeVisible(); + + const setIdServer = uut.locator(".mx_SetIdServer"); + await setIdServer.scrollIntoViewIfNeeded(); + // Assert that an input area for identity server exists + await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible(); + + const setIntegrationManager = uut.locator(".mx_SetIntegrationManager"); + await setIntegrationManager.scrollIntoViewIfNeeded(); + await expect( + setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager", { hasText: IntegrationManager }), + ).toBeVisible(); + // Make sure integration manager's toggle switch is enabled + await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible(); + await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText( + "Manage integrations(scalar.vector.im)", + ); + + // Assert the account deactivation button is displayed + const accountManagementSection = uut.getByTestId("account-management-section"); + await accountManagementSection.scrollIntoViewIfNeeded(); + await expect(accountManagementSection.getByRole("button", { name: "Deactivate Account" })).toHaveClass( + /mx_AccessibleButton_kind_danger/, + ); + }); + + test("should respond to small screen sizes", async ({ page, uut }) => { + await page.setViewportSize({ width: 700, height: 600 }); + await expect(uut).toMatchScreenshot("general-smallscreen.png"); + }); + + test("should support adding and removing a profile picture", async ({ uut, page }) => { + const profileSettings = uut.locator(".mx_UserProfileSettings"); + // Upload a picture + await profileSettings.getByAltText("Upload").setInputFiles("playwright/sample-files/riot.png"); + + // Image should be visible + await expect(profileSettings.locator(".mx_AvatarSetting_avatar img")).toBeVisible(); + + // Open the menu & click remove + await profileSettings.getByRole("button", { name: "Profile Picture" }).click(); + await page.getByRole("menuitem", { name: "Remove" }).click(); + + // Assert that the image disappeared + await expect(profileSettings.locator(".mx_AvatarSetting_avatar img")).not.toBeVisible(); + }); + + test("should set a country calling code based on default_country_code", async ({ uut }) => { + // Check phone numbers area + const accountPhoneNumbers = uut.getByTestId("mx_AccountPhoneNumbers"); + await accountPhoneNumbers.scrollIntoViewIfNeeded(); + // Assert that an input area for a new phone number is rendered + await expect(accountPhoneNumbers.getByRole("textbox", { name: "Phone Number" })).toBeVisible(); + + // Check a new phone number dropdown menu + const dropdown = accountPhoneNumbers.locator(".mx_PhoneNumbers_country"); + await dropdown.scrollIntoViewIfNeeded(); + // Assert that the country calling code of the United States is visible + await expect(dropdown.getByText(/\+1/)).toBeVisible(); + + // Click the button to display the dropdown menu + await dropdown.getByRole("button", { name: "Country Dropdown" }).click(); + + // Assert that the option for calling code of the United Kingdom is visible + await expect(dropdown.getByRole("option", { name: /United Kingdom/ })).toBeVisible(); + + // Click again to close the dropdown + await dropdown.getByRole("button", { name: "Country Dropdown" }).click(); + + // Assert that the default value is rendered again + await expect(dropdown.getByText(/\+1/)).toBeVisible(); + + await expect(accountPhoneNumbers.getByRole("button", { name: "Add" })).toBeVisible(); + }); + + test("should support changing a display name", async ({ uut, page, app }) => { + // Change the diaplay name to USER_NAME_NEW + const displayNameInput = uut + .locator(".mx_SettingsTab .mx_UserProfileSettings") + .getByRole("textbox", { name: "Display Name" }); + await displayNameInput.fill(USER_NAME_NEW); + await displayNameInput.press("Enter"); + + await app.closeDialog(); + + // Assert the avatar's initial characters are set + await expect(page.locator(".mx_UserMenu .mx_BaseAvatar").getByText("A")).toBeVisible(); // Alice + await expect(page.locator(".mx_RoomView_wrapper .mx_BaseAvatar").getByText("A")).toBeVisible(); // Alice + }); +}); diff --git a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts new file mode 100644 index 00000000000..2dbd267162d --- /dev/null +++ b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts @@ -0,0 +1,31 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Preferences user settings tab", () => { + test.use({ + displayName: "Bob", + }); + + test("should be rendered properly", async ({ app, user }) => { + const tab = await app.settings.openUserSettings("Preferences"); + + // Assert that the top heading is rendered + await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible(); + await expect(tab).toMatchScreenshot(); + }); +}); diff --git a/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts b/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts new file mode 100644 index 00000000000..8d8c2ebffa7 --- /dev/null +++ b/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts @@ -0,0 +1,58 @@ +/* + * + * Copyright 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. + * / + */ + +import { Locator } from "@playwright/test"; + +import { test, expect } from "../../element-web-test"; + +test.describe("Roles & Permissions room settings tab", () => { + const roomName = "Test room"; + + test.use({ + displayName: "Alice", + }); + + let settings: Locator; + + test.beforeEach(async ({ user, app }) => { + await app.client.createRoom({ name: roomName }); + await app.viewRoomByName(roomName); + settings = await app.settings.openRoomSettings("Roles & Permissions"); + }); + + test("should be able to change the role of a user", async ({ page, app, user }) => { + const privilegedUserSection = settings.locator(".mx_SettingsFieldset").first(); + const applyButton = privilegedUserSection.getByRole("button", { name: "Apply" }); + + // Alice is admin (100) and the Apply button should be disabled + await expect(applyButton).toBeDisabled(); + let combobox = privilegedUserSection.getByRole("combobox", { name: user.userId }); + await expect(combobox).toHaveValue("100"); + + // Change the role of Alice to Moderator (50) + await combobox.selectOption("Moderator"); + await expect(combobox).toHaveValue("50"); + await applyButton.click(); + + // Reload and check Alice is still Moderator (50) + await page.reload(); + settings = await app.settings.openRoomSettings("Roles & Permissions"); + combobox = privilegedUserSection.getByRole("combobox", { name: user.userId }); + await expect(combobox).toHaveValue("50"); + }); +}); diff --git a/playwright/e2e/settings/security-user-settings-tab.spec.ts b/playwright/e2e/settings/security-user-settings-tab.spec.ts new file mode 100644 index 00000000000..08640f603ba --- /dev/null +++ b/playwright/e2e/settings/security-user-settings-tab.spec.ts @@ -0,0 +1,51 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Security user settings tab", () => { + test.describe("with posthog enabled", () => { + test.use({ + displayName: "Hanako", + // Enable posthog + config: { + posthog: { + project_api_key: "foo", + api_host: "bar", + }, + privacy_policy_url: "example.tld", // Set privacy policy URL to enable privacyPolicyLink + }, + }); + + test.beforeEach(async ({ page, user }) => { + // Dismiss "Notification" toast + await page + .locator(".mx_Toast_toast", { hasText: "Notifications" }) + .getByRole("button", { name: "Dismiss" }) + .click(); + + await page.locator(".mx_Toast_buttons").getByRole("button", { name: "Yes" }).click(); // Allow analytics + }); + + test.describe("AnalyticsLearnMoreDialog", () => { + test("should be rendered properly", async ({ app, page }) => { + const tab = await app.settings.openUserSettings("Security"); + await tab.getByRole("button", { name: "Learn more" }).click(); + await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot(); + }); + }); + }); +}); 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 00000000000..e1efa7ec6f9 --- /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 new file mode 100644 index 00000000000..0e7aa0e4f4d --- /dev/null +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -0,0 +1,296 @@ +/* +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 { Locator, Page } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import type { Preset, ICreateRoomOpts } from "matrix-js-sdk/src/matrix"; +import { ElementAppPage } from "../../pages/ElementAppPage"; + +async function openSpaceCreateMenu(page: Page): Promise { + await page.getByRole("button", { name: "Create a space" }).click(); + return page.locator(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu"); +} + +async function openSpaceContextMenu(page: Page, app: ElementAppPage, spaceName: string): Promise { + const button = await app.getSpacePanelButton(spaceName); + await button.click({ button: "right" }); + return page.locator(".mx_SpacePanel_contextMenu"); +} + +function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateRoomOpts { + return { + creation_content: { + type: "m.space", + }, + initial_state: [ + { + type: "m.room.name", + content: { + name: spaceName, + }, + }, + ...roomIds.map(spaceChildInitialState), + ], + }; +} + +function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state"]["0"] { + return { + type: "m.space.child", + state_key: roomId, + content: { + via: [roomId.split(":")[1]], + }, + }; +} + +test.describe("Spaces", () => { + test.use({ + displayName: "Sue", + botCreateOpts: { displayName: "BotBob" }, + }); + + test("should allow user to create public space", async ({ page, app, user }) => { + const contextMenu = await openSpaceCreateMenu(page); + await expect(contextMenu).toMatchScreenshot("space-create-menu.png"); + + await contextMenu.getByRole("button", { name: /Public/ }).click(); + + await contextMenu + .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .setInputFiles("playwright/sample-files/riot.png"); + await contextMenu.getByRole("textbox", { name: "Name" }).fill("Let's have a Riot"); + await expect(contextMenu.getByRole("textbox", { name: "Address" })).toHaveValue("lets-have-a-riot"); + await contextMenu.getByRole("textbox", { name: "Description" }).fill("This is a space to reminisce Riot.im!"); + await contextMenu.getByRole("button", { name: "Create" }).click(); + + // Create the default General & Random rooms, as well as a custom "Jokes" room + await expect(page.getByPlaceholder("General")).toBeVisible(); + await expect(page.getByPlaceholder("Random")).toBeVisible(); + await page.getByPlaceholder("Support").fill("Jokes"); + await page.getByRole("button", { name: "Continue" }).click(); + + // Copy matrix.to link + await page.getByRole("button", { name: "Share invite link" }).click(); + expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#lets-have-a-riot:localhost"); + + // Go to space home + await page.getByRole("button", { name: "Go to my first room" }).click(); + + // Assert rooms exist in the room list + await expect(page.getByRole("treeitem", { name: "General" })).toBeVisible(); + await expect(page.getByRole("treeitem", { name: "Random" })).toBeVisible(); + await expect(page.getByRole("treeitem", { name: "Jokes" })).toBeVisible(); + }); + + test("should allow user to create private space", async ({ page, app, user }) => { + const menu = await openSpaceCreateMenu(page); + await menu.getByRole("button", { name: "Private" }).click(); + + await menu + .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .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..."); + await menu.getByRole("button", { name: "Create" }).click(); + + await page.getByRole("button", { name: "Me and my teammates" }).click(); + + // Create the default General & Random rooms, as well as a custom "Projects" room + await expect(page.getByPlaceholder("General")).toBeVisible(); + await expect(page.getByPlaceholder("Random")).toBeVisible(); + await page.getByPlaceholder("Support").fill("Projects"); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page.locator(".mx_SpaceRoomView h1").getByText("Invite your teammates")).toBeVisible(); + await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("invite-teammates-dialog.png"); + await page.getByRole("button", { name: "Skip for now" }).click(); + + // Assert rooms exist in the room list + await expect(page.getByRole("treeitem", { name: "General", exact: true })).toBeVisible(); + await expect(page.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible(); + await expect(page.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible(); + + // Assert rooms exist in the space explorer + await expect( + page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "General" }), + ).toBeVisible(); + await expect( + page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Random" }), + ).toBeVisible(); + await expect( + page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Projects" }), + ).toBeVisible(); + }); + + test("should allow user to create just-me space", async ({ page, app, user }) => { + await app.client.createRoom({ + name: "Sample Room", + }); + + const menu = await openSpaceCreateMenu(page); + await menu.getByRole("button", { name: "Private" }).click(); + + await menu + .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .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"); + await menu.getByRole("textbox", { name: "Name" }).press("Enter"); + + await page.getByRole("button", { name: "Just me" }).click(); + + await page.getByText("Sample Room").click({ force: true }); // force click as checkbox size is zero + + // Temporal implementation as multiple elements with the role "button" and name "Add" are found + await page.locator(".mx_AddExistingToSpace_footer").getByRole("button", { name: "Add" }).click(); + + await expect( + page.locator(".mx_SpaceHierarchy_list").getByRole("treeitem", { name: "Sample Room" }), + ).toBeVisible(); + }); + + test("should allow user to invite another to a space", async ({ page, app, user, bot }) => { + await app.client.createSpace({ + visibility: "public" as any, + room_alias_name: "space", + }); + + const menu = await openSpaceContextMenu(page, app, "#space:localhost"); + await menu.getByRole("menuitem", { name: "Invite" }).click(); + + const shareDialog = page.locator(".mx_SpacePublicShare"); + // Copy link first + await shareDialog.getByRole("button", { name: "Share invite link" }).click(); + expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#space:localhost"); + // Start Matrix invite flow + await shareDialog.getByRole("button", { name: "Invite people" }).click(); + + const otherSection = page.locator(".mx_InviteDialog_other"); + await otherSection.getByRole("textbox").fill(bot.credentials.userId); + await otherSection.getByRole("button", { name: "Invite" }).click(); + + await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible(); + }); + + test("should show space invites at the top of the space panel", async ({ page, app, user, bot }) => { + await app.client.createSpace({ + name: "My Space", + }); + await expect(await app.getSpacePanelButton("My Space")).toBeVisible(); + + const roomId = await bot.createRoom(spaceCreateOptions("Space Space")); + await bot.inviteUser(roomId, user.userId); + + // Assert that `Space Space` is above `My Space` due to it being an invite + const buttons = page.getByRole("tree", { name: "Spaces" }).locator(".mx_SpaceButton"); + await expect(buttons.nth(1)).toHaveAttribute("aria-label", "Space Space"); + await expect(buttons.nth(2)).toHaveAttribute("aria-label", "My Space"); + }); + + test("should include rooms in space home", async ({ page, app, user }) => { + const roomId1 = await app.client.createRoom({ + name: "Music", + }); + const roomId2 = await app.client.createRoom({ + name: "Gaming", + }); + + const spaceName = "Spacey Mc. Space Space"; + await app.client.createSpace({ + name: spaceName, + initial_state: [spaceChildInitialState(roomId1), spaceChildInitialState(roomId2)], + }); + + await app.viewSpaceHomeByName(spaceName); + + const hierarchyList = page.locator(".mx_SpaceRoomView .mx_SpaceHierarchy_list"); + await expect(hierarchyList.getByRole("treeitem", { name: "Music" }).getByRole("button")).toBeVisible(); + await expect(hierarchyList.getByRole("treeitem", { name: "Gaming" }).getByRole("button")).toBeVisible(); + }); + + test("should render subspaces in the space panel only when expanded", async ({ + page, + app, + user, + axe, + checkA11y, + }) => { + axe.disableRules([ + // Disable this check as it triggers on nested roving tab index elements which are in practice fine + "nested-interactive", + // XXX: We have some known contrast issues here + "color-contrast", + ]); + + const childSpaceId = await app.client.createSpace({ + name: "Child Space", + initial_state: [], + }); + await app.client.createSpace({ + name: "Root Space", + initial_state: [spaceChildInitialState(childSpaceId)], + }); + + // Find collapsed Space panel + const spaceTree = page.getByRole("tree", { name: "Spaces" }); + await expect(spaceTree.getByRole("button", { name: "Root Space" })).toBeVisible(); + await expect(spaceTree.getByRole("button", { name: "Child Space" })).not.toBeVisible(); + + await checkA11y(); + await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-collapsed.png"); + + // This finds the expand button with the class name "mx_SpaceButton_toggleCollapse". Note there is another + // button with the same name with different class name "mx_SpacePanel_toggleCollapse". + await spaceTree.getByRole("button", { name: "Expand" }).click(); + await expect(page.locator(".mx_SpacePanel:not(.collapsed)")).toBeVisible(); // TODO: replace :not() selector + + const item = page.locator(".mx_SpaceItem", { hasText: "Root Space" }); + await expect(item).toBeVisible(); + await expect(item.locator(".mx_SpaceItem", { hasText: "Child Space" })).toBeVisible(); + + await checkA11y(); + await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-expanded.png"); + }); + + test("should not soft crash when joining a room from space hierarchy which has a link in its topic", async ({ + page, + app, + user, + bot, + }) => { + const roomId = await bot.createRoom({ + preset: "public_chat" as Preset, + name: "Test Room", + topic: "This is a topic https://github.com/matrix-org/matrix-react-sdk/pull/10060 with a link", + }); + const spaceId = await bot.createRoom(spaceCreateOptions("Test Space", [roomId])); + await bot.inviteUser(spaceId, user.userId); + + await expect(await app.getSpacePanelButton("Test Space")).toBeVisible(); + await app.viewSpaceByName("Test Space"); + await page.getByRole("button", { name: "Accept" }).click(); + + await page.getByRole("button", { name: "Test Room" }).hover(); + await page.getByRole("button", { name: "Join", exact: true }).click(); + await page.getByRole("button", { name: "View", exact: true }).click(); + + // Assert we get shown the new room intro, and thus not the soft crash screen + await expect(page.locator(".mx_NewRoomIntro")).toBeVisible(); + }); +}); diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts new file mode 100644 index 00000000000..8bafe2e8049 --- /dev/null +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -0,0 +1,400 @@ +/* +Copyright 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. +*/ + +import { JSHandle, Locator, Page } from "@playwright/test"; + +import type { MatrixEvent, IContent, Room } from "matrix-js-sdk/src/matrix"; +import { test as base, expect } from "../../../element-web-test"; +import { Bot } from "../../../pages/bot"; +import { Client } from "../../../pages/client"; +import { ElementAppPage } from "../../../pages/ElementAppPage"; + +/** + * Set up for a read receipt test: + * - Create a user with the supplied name + * - As that user, create two rooms with the supplied names + * - Create a bot with the supplied name + * - Invite the bot to both rooms and ensure that it has joined + */ +export const test = base.extend<{ + room1Name?: string; + room1: { name: string; roomId: string }; + room2Name?: string; + room2: { name: string; roomId: string }; + msg: MessageBuilder; + util: Helpers; +}>({ + displayName: "Mae", + botCreateOpts: { displayName: "Other User" }, + + room1Name: "Room 1", + room1: async ({ room1Name: name, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await use({ name, roomId }); + }, + room2Name: "Room 2", + room2: async ({ room2Name: name, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await use({ name, roomId }); + }, + msg: async ({ page, app, util }, use) => { + await use(new MessageBuilder(page, app, util)); + }, + util: async ({ room1, room2, page, app, bot }, use) => { + await use(new Helpers(page, app, bot)); + }, +}); + +/** + * A utility that is able to find messages based on their content, by looking + * inside the `timeline` objects in the object model. + * + * Crucially, we hold on to references to events that have been edited or + * redacted, so we can still look them up by their old content. + * + * Provides utilities that build on the ability to find messages, e.g. replyTo, + * which finds a message and then constructs a reply to it. + */ +export class MessageBuilder { + constructor( + private page: Page, + private app: ElementAppPage, + private helpers: Helpers, + ) {} + + /** + * Map of message content -> event. + */ + messages = new Map>>(); + + /** + * Utility to find a MatrixEvent by its body content + * @param room - the room to search for the event in + * @param message - the body of the event to search for + * @param includeThreads - whether to search within threads too + */ + async getMessage(room: JSHandle, message: string, includeThreads = false): Promise> { + const cached = this.messages.get(message); + if (cached) { + return cached; + } + + const promise = room.evaluateHandle( + async (room, { message, includeThreads }) => { + let ev = room.timeline.find((e) => e.getContent().body === message); + if (!ev && includeThreads) { + for (const thread of room.getThreads()) { + ev = thread.timeline.find((e) => e.getContent().body === message); + if (ev) break; + } + } + + if (ev) return ev; + + return new Promise((resolve) => { + room.on("Room.timeline" as any, (ev: MatrixEvent) => { + if (ev.getContent().body === message) { + resolve(ev); + } + }); + }); + }, + { message, includeThreads }, + ); + + this.messages.set(message, promise); + return promise; + } + + /** + * MessageContentSpec to send a threaded response into a room + * @param rootMessage - the body of the thread root message to send a response to + * @param newMessage - the message body to send into the thread response or an object with the message content + */ + threadedOff(rootMessage: string, newMessage: string | IContent): MessageContentSpec { + return new (class extends MessageContentSpec { + public async getContent(room: JSHandle): Promise> { + const ev = await this.messageFinder.getMessage(room, rootMessage); + return ev.evaluate((ev, newMessage) => { + if (typeof newMessage === "string") { + return { + "msgtype": "m.text", + "body": newMessage, + "m.relates_to": { + event_id: ev.getId(), + is_falling_back: true, + rel_type: "m.thread", + }, + }; + } else { + return { + "msgtype": "m.text", + "m.relates_to": { + event_id: ev.getId(), + is_falling_back: true, + rel_type: "m.thread", + }, + ...newMessage, + }; + } + }, newMessage); + } + })(this); + } +} + +/** + * Something that can provide the content of a message. + * + * For example, we return and instance of this from {@link + * MessageBuilder.replyTo} which creates a reply based on a previous message. + */ +export abstract class MessageContentSpec { + messageFinder: MessageBuilder | null; + + constructor(messageFinder: MessageBuilder = null) { + this.messageFinder = messageFinder; + } + + public abstract getContent(room: JSHandle): Promise>; +} + +/** + * Something that we will turn into a message or event when we pass it in to + * e.g. receiveMessages. + */ +export type Message = string | MessageContentSpec; + +export class Helpers { + constructor( + private page: Page, + private app: ElementAppPage, + private bot: Bot, + ) {} + + /** + * Use the supplied client to send messages or perform actions as specified by + * the supplied {@link Message} items. + */ + async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: Message[]) { + const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name); + const roomId = await room.evaluate((room) => room.roomId); + + for (const message of messages) { + if (typeof message === "string") { + await cli.sendMessage(roomId, { body: message, msgtype: "m.text" }); + } else if (message instanceof MessageContentSpec) { + await cli.sendMessage(roomId, await message.getContent(room)); + } + // TODO: without this wait, some tests that send lots of messages flake + // from time to time. I (andyb) have done some investigation, but it + // needs more work to figure out. The messages do arrive over sync, but + // they never appear in the timeline, and they never fire a + // Room.timeline event. I think this only happens with events that refer + // to other events (e.g. replies), so it might be caused by the + // referring event arriving before the referred-to event. + await this.page.waitForTimeout(100); + } + } + + /** + * Open the room with the supplied name. + */ + async goTo(room: string | { name: string }) { + await this.app.viewRoomByName(typeof room === "string" ? room : room.name); + } + + /** + * Click the thread with the supplied content in the thread root to open it in + * the Threads panel. + */ + async openThread(rootMessage: string) { + const tile = this.page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: rootMessage }); + await tile.hover(); + await tile.getByRole("button", { name: "Reply in thread" }).click(); + await expect(this.page.locator(".mx_ThreadView_timelinePanelWrapper")).toBeVisible(); + } + + async findRoomByName(roomName: string): Promise> { + return this.app.client.evaluateHandle((cli, roomName) => { + return cli.getRooms().find((r) => r.name === roomName); + }, roomName); + } + + /** + * Sends messages into given room as a bot + * @param room - the name of the room to send messages into + * @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf` + */ + async receiveMessages(room: string | { name: string }, messages: Message[]) { + await this.sendMessageAsClient(this.bot, room, messages); + } + + /** + * Get the threads activity centre button + * @private + */ + private getTacButton(): Locator { + return this.page.getByRole("navigation", { name: "Spaces" }).getByLabel("Threads"); + } + + /** + * Return the threads activity centre panel + */ + getTacPanel() { + return this.page.getByRole("menu", { name: "Threads" }); + } + + /** + * Open the Threads Activity Centre + */ + openTac() { + return this.getTacButton().click(); + } + + /** + * Hover over the Threads Activity Centre button + */ + hoverTacButton() { + return this.getTacButton().hover(); + } + + /** + * Click on a room in the Threads Activity Centre + * @param name - room name + */ + clickRoomInTac(name: string) { + return this.getTacPanel().getByRole("menuitem", { name }).click(); + } + + /** + * Assert that the threads activity centre button has no indicator + */ + async assertNoTacIndicator() { + // Assert by checkng neither of the known indicators are visible first. This will wait + // if it takes a little time to disappear, but the screenshot comparison won't. + await expect(this.getTacButton().locator("[data-indicator='success']")).not.toBeVisible(); + await expect(this.getTacButton().locator("[data-indicator='critical']")).not.toBeVisible(); + await expect(this.getTacButton()).toMatchScreenshot("tac-no-indicator.png"); + } + + /** + * Assert that the threads activity centre button has a notification indicator + */ + assertNotificationTac() { + return expect(this.getTacButton().locator("[data-indicator='success']")).toBeVisible(); + } + + /** + * Assert that the threads activity centre button has a highlight indicator + */ + assertHighlightIndicator() { + return expect(this.getTacButton().locator("[data-indicator='critical']")).toBeVisible(); + } + + /** + * Assert that the threads activity centre panel has the expected rooms + * @param content - the expected rooms and their notification levels + */ + async assertRoomsInTac(content: Array<{ room: string; notificationLevel: "highlight" | "notification" }>) { + const getBadgeClass = (notificationLevel: "highlight" | "notification") => + notificationLevel === "highlight" + ? "mx_NotificationBadge_level_highlight" + : "mx_NotificationBadge_level_notification"; + + // Ensure that we have the right number of rooms + await expect(this.getTacPanel().getByRole("menuitem")).toHaveCount(content.length); + + // Ensure that each room is present in the correct order and has the correct notification level + const roomsLocator = this.getTacPanel().getByRole("menuitem"); + for (const [index, { room, notificationLevel }] of content.entries()) { + const roomLocator = roomsLocator.nth(index); + // Ensure that the room name are correct + await expect(roomLocator).toHaveText(new RegExp(room)); + // There is no accessibility marker for the StatelessNotificationBadge + await expect(roomLocator.locator(`.${getBadgeClass(notificationLevel)}`)).toBeVisible(); + } + } + + /** + * Assert that the thread panel is opened + */ + assertThreadPanelIsOpened() { + return expect(this.page.locator(".mx_ThreadPanel")).toBeVisible(); + } + + /** + * Assert that the thread panel is focused (actually the 'close' button, specifically) + */ + assertThreadPanelFocused() { + return expect( + this.page.locator(".mx_ThreadPanel").locator(".mx_BaseCard_header").getByLabel("Close"), + ).toBeFocused(); + } + + /** + * Populate the rooms with messages and threads + * @param room1 + * @param room2 + * @param msg - MessageBuilder + * @param hasMention - whether to include a mention in the first message + */ + async populateThreads( + room1: { name: string; roomId: string }, + room2: { name: string; roomId: string }, + msg: MessageBuilder, + hasMention = true, + ) { + if (hasMention) { + await this.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", { + "body": "User", + "format": "org.matrix.custom.html", + "formatted_body": "User", + "m.mentions": { + user_ids: ["@user:localhost"], + }, + }), + ]); + } + await this.receiveMessages(room2, ["Msg2", msg.threadedOff("Msg2", "Resp2")]); + await this.receiveMessages(room1, ["Msg3", msg.threadedOff("Msg3", "Resp3")]); + } + + /** + * Get the space panel + */ + getSpacePanel() { + return this.page.getByRole("navigation", { name: "Spaces" }); + } + + /** + * Expand the space panel + */ + expandSpacePanel() { + return this.page.getByRole("button", { name: "Expand" }).click(); + } + + /** + * Clicks the button to mark all threads as read in the current room + */ + clickMarkAllThreadsRead() { + return this.page.getByLabel("Mark all as read").click(); + } +} + +export { expect }; diff --git a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts new file mode 100644 index 00000000000..7d0b694ef57 --- /dev/null +++ b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -0,0 +1,177 @@ +/* + * + * Copyright 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. + * / + */ + +import { expect, test } from "."; +import { CommandOrControl } from "../../utils"; + +test.describe("Threads Activity Centre", () => { + test.use({ + displayName: "Alice", + botCreateOpts: { displayName: "Other User" }, + labsFlags: ["threadsActivityCentre"], + }); + + test("should have the button correctly aligned and displayed in the space panel when expanded", async ({ + util, + }) => { + // Open the space panel + await util.expandSpacePanel(); + // The buttons in the space panel should be aligned when expanded + await expect(util.getSpacePanel()).toMatchScreenshot("tac-button-expanded.png"); + }); + + test("should not show indicator when there is no thread", async ({ room1, util }) => { + // No indicator should be shown + await util.assertNoTacIndicator(); + + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1"]); + + // A message in the main timeline should not affect the indicator + await util.assertNoTacIndicator(); + }); + + test("should show a notification indicator when there is a message in a thread", async ({ room1, util, msg }) => { + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + + // The indicator should be shown + await util.assertNotificationTac(); + }); + + test("should show a highlight indicator when there is a mention in a thread", async ({ room1, util, msg }) => { + await util.goTo(room1); + await util.receiveMessages(room1, [ + "Msg1", + msg.threadedOff("Msg1", { + "body": "User", + "format": "org.matrix.custom.html", + "formatted_body": "User", + "m.mentions": { + user_ids: ["@user:localhost"], + }, + }), + ]); + + // The indicator should be shown + await util.assertHighlightIndicator(); + }); + + test("should show the rooms with unread threads", async ({ room1, room2, util, msg }) => { + await util.goTo(room2); + await util.populateThreads(room1, room2, msg); + // The indicator should be shown + await util.assertHighlightIndicator(); + + // Verify that we have the expected rooms in the TAC + await util.openTac(); + await util.assertRoomsInTac([ + { room: room2.name, notificationLevel: "highlight" }, + { room: room1.name, notificationLevel: "notification" }, + ]); + + // Verify that we don't have a visual regression + await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-mix-unread.png"); + }); + + test("should update with a thread is read", async ({ room1, room2, util, msg }) => { + await util.goTo(room2); + await util.populateThreads(room1, room2, msg); + + // Click on the first room in TAC + await util.openTac(); + await util.clickRoomInTac(room2.name); + + // Verify that the thread panel is opened after a click on the room in the TAC + await util.assertThreadPanelIsOpened(); + + // Open a thread and mark it as read + // The room 2 doesn't have a mention anymore in its unread, so the highest notification level is notification + await util.openThread("Msg1"); + await util.assertNotificationTac(); + await util.openTac(); + await util.assertRoomsInTac([ + { room: room1.name, notificationLevel: "notification" }, + { room: room2.name, notificationLevel: "notification" }, + ]); + await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-notification-unread.png"); + }); + + test("should order by recency after notification level", async ({ room1, room2, util, msg }) => { + await util.goTo(room2); + await util.populateThreads(room1, room2, msg, false); + + await util.openTac(); + await util.assertRoomsInTac([ + { room: room1.name, notificationLevel: "notification" }, + { room: room2.name, notificationLevel: "notification" }, + ]); + }); + + test("should block the Spotlight to open when the TAC is opened", async ({ util, page }) => { + const toggleSpotlight = () => page.keyboard.press(`${CommandOrControl}+k`); + + // Sanity check + // Open and close the spotlight + await toggleSpotlight(); + await expect(page.locator(".mx_SpotlightDialog")).toBeVisible(); + await toggleSpotlight(); + + await util.openTac(); + // The spotlight should not be opened + await toggleSpotlight(); + await expect(page.locator(".mx_SpotlightDialog")).not.toBeVisible(); + }); + + test("should have the correct hover state", async ({ util, page }) => { + await util.hoverTacButton(); + await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered.png"); + + // Expand the space panel, hover the button and take a screenshot + await util.expandSpacePanel(); + await util.hoverTacButton(); + await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered-expanded.png"); + }); + + test("should mark all threads as read", async ({ room1, room2, util, msg, page }) => { + await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + + await util.assertNotificationTac(); + + await util.openTac(); + await util.clickRoomInTac(room1.name); + + util.clickMarkAllThreadsRead(); + + await util.assertNoTacIndicator(); + }); + + test("should focus the thread panel close button when clicking an item in the TAC", async ({ + room1, + room2, + util, + msg, + }) => { + await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + + await util.openTac(); + await util.clickRoomInTac(room1.name); + + await util.assertThreadPanelFocused(); + }); +}); diff --git a/playwright/e2e/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts new file mode 100644 index 00000000000..177eccdc106 --- /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 00000000000..9b5ea46511e --- /dev/null +++ b/playwright/e2e/threads/threads.spec.ts @@ -0,0 +1,518 @@ +/* +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(); + }); + + test("navigate through right panel", async ({ page, app, user }) => { + // Create room + const roomId = await app.client.createRoom({}); + await page.goto("/#/room/" + roomId); + + /** + * Send a message in the main timeline + * @param message + */ + const sendMessage = async (message: string) => { + const messageComposer = page.getByRole("region", { name: "Message composer" }); + const textbox = messageComposer.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill(message); + await textbox.press("Enter"); + }; + + /** + * Create a thread from the rootMessage and send a message in the thread + * @param rootMessage + * @param threadMessage + */ + const createThread = async (rootMessage: string, threadMessage: string) => { + // First create a thread + const roomViewBody = page.locator(".mx_RoomView_body"); + const messageTile = roomViewBody + .locator(".mx_EventTile[data-scroll-tokens]") + .filter({ hasText: rootMessage }); + await messageTile.hover(); + await messageTile.getByRole("button", { name: "Reply in thread" }).click(); + await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1); + + // Send a message in the thread + const threadPanel = page.locator(".mx_ThreadPanel"); + const textbox = threadPanel.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill(threadMessage); + await textbox.press("Enter"); + await expect(threadPanel.locator(".mx_EventTile_last").getByText(threadMessage)).toBeVisible(); + // Close thread + await threadPanel.getByRole("button", { name: "Close" }).click(); + }; + + await sendMessage("Hello Mr. Bot"); + await sendMessage("Hello again Mr. Bot"); + await createThread("Hello Mr. Bot", "Hello Mr. User in a thread"); + await createThread("Hello again Mr. Bot", "Hello again Mr. User in a thread"); + + // Open thread panel + await page.getByTestId("threadsButton").click(); + const threadPanel = page.locator(".mx_ThreadPanel"); + await expect( + threadPanel.locator(".mx_EventTile_last").getByText("Hello again Mr. User in a thread"), + ).toBeVisible(); + + // Open threads list + await page.locator(".mx_BaseCard_back").click(); + const rightPanel = page.locator(".mx_RightPanel"); + // Check that the threads are listed + await expect(rightPanel.locator(".mx_EventTile").getByText("Hello Mr. User in a thread")).toBeVisible(); + await expect(rightPanel.locator(".mx_EventTile").getByText("Hello again Mr. User in a thread")).toBeVisible(); + + // Open the first thread + await rightPanel.locator(".mx_EventTile").getByText("Hello Mr. User in a thread").click(); + await expect(rightPanel.locator(".mx_EventTile").getByText("Hello Mr. User in a thread")).toBeVisible(); + await expect( + rightPanel.locator(".mx_EventTile").getByText("Hello again Mr. User in a thread"), + ).not.toBeVisible(); + }); +}); diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts new file mode 100644 index 00000000000..60aa1e2a27b --- /dev/null +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -0,0 +1,1242 @@ +/* +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, false, true); + }, + { 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); +}; + +const sendImage = async ( + client: Client, + roomId: string, + pngBytes: Buffer, + additionalContent?: any, +): Promise => { + const upload = await client.uploadContent(pngBytes, { name: "image.png", type: "image/png" }); + return client.sendEvent(roomId, null, "m.room.message" as EventType, { + ...(additionalContent ?? {}), + + msgtype: "m.image" as MsgType, + body: "image.png", + url: upload.content_uri, + }); +}; + +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, cryptoBackend }) => { + test.skip( + cryptoBackend === "rust", + "Disabled due to screenshot test being flaky - https://github.com/element-hq/element-web/issues/26890", + ); + await sendEvent(app.client, room.roomId); + await page.goto(`/#/room/${room.roomId}`); + await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); + 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"; + + const newDisplayName = `${LONG_STRING} 2`; + + // 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. + // Note that we set it here as the test was failing on CI (but not locally!) if the name + // was changed afterwards. This is quite concerning, but maybe better than just disabling the + // whole test? + // https://github.com/element-hq/element-web/issues/27109 + await app.client.setDisplayName(newDisplayName); + + // 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(newDisplayName + " created and configured the room."), + ).toBeVisible(); + + // 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, + ); + }); + + async function testImageRendering(page: Page, app: ElementAppPage, room: { roomId: string }) { + await app.viewRoomById(room.roomId); + + // Reinstall the service workers to clear their implicit caches (global-level stuff) + await page.evaluate(async () => { + const registrations = await window.navigator.serviceWorker.getRegistrations(); + registrations.forEach((r) => r.update()); + }); + + await sendImage(app.client, room.roomId, NEW_AVATAR); + await expect(page.locator(".mx_MImageBody").first()).toBeVisible(); + + // 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_ScrollPanel")).toMatchScreenshot( + "image-in-timeline-default-layout.png", + screenshotOptions, + ); + } + + test("should render images in the timeline", async ({ page, app, room, context }) => { + await testImageRendering(page, app, room); + }); + + // XXX: This test doesn't actually work because the service worker relies on IndexedDB, which Playwright forces + // to be a localstorage implementation, which service workers cannot access. + // See https://github.com/microsoft/playwright/issues/11164 + // See https://github.com/microsoft/playwright/issues/15684#issuecomment-2070862042 + // + // In practice, this means this test will *always* succeed because it ends up relying on fallback behaviour tested + // above (unless of course the above tests are also broken). + test.describe("MSC3916 - Authenticated Media", () => { + test("should render authenticated images in the timeline", async ({ page, app, room, context }) => { + // Note: we have to use `context` instead of `page` for routing, otherwise we'll miss Service Worker events. + // See https://playwright.dev/docs/service-workers-experimental#network-events-and-routing + + // Install our mocks and preventative measures + await context.route("**/_matrix/client/versions", async (route) => { + // Force enable MSC3916, which may require the service worker's internal cache to be cleared later. + const json = await (await route.fetch()).json(); + if (!json["unstable_features"]) json["unstable_features"] = {}; + json["unstable_features"]["org.matrix.msc3916"] = true; + await route.fulfill({ json }); + }); + await context.route("**/_matrix/media/*/download/**", async (route) => { + // should not be called. We don't use `abort` so that it's clearer in the logs what happened. + await route.fulfill({ + status: 500, + json: { errcode: "M_UNKNOWN", error: "Unexpected route called." }, + }); + }); + await context.route("**/_matrix/media/*/thumbnail/**", async (route) => { + // should not be called. We don't use `abort` so that it's clearer in the logs what happened. + await route.fulfill({ + status: 500, + json: { errcode: "M_UNKNOWN", error: "Unexpected route called." }, + }); + }); + await context.route("**/_matrix/client/unstable/org.matrix.msc3916/download/**", async (route) => { + expect(route.request().headers()["Authorization"]).toBeDefined(); + // we can't use route.continue() because no configured homeserver supports MSC3916 yet + await route.fulfill({ + body: NEW_AVATAR, + }); + }); + await context.route("**/_matrix/client/unstable/org.matrix.msc3916/thumbnail/**", async (route) => { + expect(route.request().headers()["Authorization"]).toBeDefined(); + // we can't use route.continue() because no configured homeserver supports MSC3916 yet + await route.fulfill({ + body: NEW_AVATAR, + }); + }); + + // We check the same screenshot because there should be no user-visible impact to using authentication. + await testImageRendering(page, app, room); + }); + }); + }); +}); diff --git a/playwright/e2e/toasts/analytics-toast.spec.ts b/playwright/e2e/toasts/analytics-toast.spec.ts new file mode 100644 index 00000000000..2ce2f82962d --- /dev/null +++ b/playwright/e2e/toasts/analytics-toast.spec.ts @@ -0,0 +1,53 @@ +/* +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 { test } from "../../element-web-test"; + +test.describe("Analytics Toast", () => { + test.use({ + displayName: "Tod", + }); + + test("should not show an analytics toast if config has nothing about posthog", async ({ user, toasts }) => { + await toasts.rejectToast("Notifications"); + await toasts.assertNoToasts(); + }); + + test.describe("with posthog enabled", () => { + test.use({ + config: { + posthog: { + project_api_key: "foo", + api_host: "bar", + }, + }, + }); + + test.beforeEach(async ({ user, toasts }) => { + await toasts.rejectToast("Notifications"); + }); + + test("should show an analytics toast which can be accepted", async ({ user, toasts }) => { + await toasts.acceptToast("Help improve Element"); + await toasts.assertNoToasts(); + }); + + test("should show an analytics toast which can be rejected", async ({ user, toasts }) => { + await toasts.rejectToast("Help improve Element"); + await toasts.assertNoToasts(); + }); + }); +}); diff --git a/playwright/e2e/update/update.spec.ts b/playwright/e2e/update/update.spec.ts new file mode 100644 index 00000000000..6af46e83e3c --- /dev/null +++ b/playwright/e2e/update/update.spec.ts @@ -0,0 +1,44 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Update", () => { + const NEW_VERSION = "some-new-version"; + + test.use({ + displayName: "Ursa", + }); + + test.beforeEach(async ({ context }) => { + await context.route("/version*", async (route) => { + await route.fulfill({ + body: NEW_VERSION, + headers: { + "Content-Type": "test/plain", + }, + }); + }); + }); + + test("should navigate to ?updated=$VERSION if realises it is immediately out of date on load", async ({ + page, + user, + }) => { + await expect(page).toHaveURL(/updated=/); + expect(new URL(page.url()).searchParams.get("updated")).toEqual(NEW_VERSION); + }); +}); diff --git a/cypress/support/e2e.ts b/playwright/e2e/user-menu/user-menu.spec.ts similarity index 50% rename from cypress/support/e2e.ts rename to playwright/e2e/user-menu/user-menu.spec.ts index 4f268966a35..d727ae7b129 100644 --- a/cypress/support/e2e.ts +++ b/playwright/e2e/user-menu/user-menu.spec.ts @@ -14,28 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -/// +import { test, expect } from "../../element-web-test"; -import "@percy/cypress"; -import "cypress-real-events"; -import "@testing-library/cypress/add-commands"; +test.describe("User Menu", () => { + test.use({ displayName: "Jeff" }); -import "./config.json"; -import "./homeserver"; -import "./login"; -import "./labs"; -import "./client"; -import "./settings"; -import "./bot"; -import "./clipboard"; -import "./util"; -import "./app"; -import "./percy"; -import "./webserver"; -import "./views"; -import "./iframes"; -import "./timeline"; -import "./network"; -import "./composer"; -import "./proxy"; -import "./axe"; + test("should contain our name & userId", async ({ page, user }) => { + await page.getByRole("button", { name: "User menu", exact: true }).click(); + const menu = page.getByRole("menu"); + + await expect(menu.locator(".mx_UserMenu_contextMenu_displayName", { hasText: user.displayName })).toBeVisible(); + await expect(menu.locator(".mx_UserMenu_contextMenu_userId", { hasText: user.userId })).toBeVisible(); + }); +}); diff --git a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts new file mode 100644 index 00000000000..09a140d4418 --- /dev/null +++ b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts @@ -0,0 +1,85 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("User Onboarding (new user)", () => { + test.use({ + displayName: "Jane Doe", + }); + + // This first beforeEach happens before the `user` fixture runs + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("mx_registration_time", "1656633601"); + }); + }); + + test.beforeEach(async ({ page, user }) => { + await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible(); + await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible(); + await expect(page.locator(".mx_UserOnboardingList")).toBeVisible(); + }); + + test("page is shown and preference exists", async ({ page, app }) => { + await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot(); + await app.settings.openUserSettings("Preferences"); + await expect(page.getByText("Show shortcut to welcome checklist above the room list")).toBeVisible(); + }); + + test("app download dialog", async ({ page }) => { + await page.getByRole("button", { name: "Download apps" }).click(); + await expect( + page.getByRole("dialog").getByRole("heading", { level: 1, name: "Download Element" }), + ).toBeVisible(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot( + "User-Onboarding-new-user-app-download-dialog-1.png", + { + // Set a constant bg behind the modal to ensure screenshot stability + css: ` + .mx_AppDownloadDialog_wrapper { + background: black; + } + `, + }, + ); + }); + + test("using find friends action should increase progress", async ({ page, homeserver }) => { + const bot = await homeserver.registerUser("botbob", "password", "BotBob"); + + const oldProgress = parseFloat(await page.getByRole("progressbar").getAttribute("value")); + await page.getByRole("button", { name: "Find friends" }).click(); + await page.locator(".mx_InviteDialog_editor").getByRole("textbox").fill(bot.userId); + await page.getByRole("button", { name: "Go" }).click(); + await expect(page.locator(".mx_InviteDialog_buttonAndSpinner")).not.toBeVisible(); + + const message = "Hi!"; + const composer = page.getByRole("textbox", { name: "Send a message…" }); + await composer.fill(`${message}`); + await composer.press("Enter"); + await expect(page.locator(".mx_MTextBody.mx_EventTile_content", { hasText: message })).toBeVisible(); + + await page.goto("/#/home"); + await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible(); + await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible(); + await expect(page.locator(".mx_UserOnboardingList")).toBeVisible(); + + await page.waitForTimeout(500); // await progress bar animation + const progress = parseFloat(await page.getByRole("progressbar").getAttribute("value")); + expect(progress).toBeGreaterThan(oldProgress); + }); +}); diff --git a/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts new file mode 100644 index 00000000000..d9be78f3495 --- /dev/null +++ b/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts @@ -0,0 +1,36 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("User Onboarding (old user)", () => { + test.use({ + displayName: "Jane Doe", + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("mx_registration_time", "2"); + }); + }); + + test("page and preference are hidden", async ({ page, user, app }) => { + await expect(page.locator(".mx_UserOnboardingPage")).not.toBeVisible(); + await expect(page.locator(".mx_UserOnboardingButton")).not.toBeVisible(); + await app.settings.openUserSettings("Preferences"); + await expect(page.getByText("Show shortcut to welcome checklist above the room list")).not.toBeVisible(); + }); +}); diff --git a/playwright/e2e/user-view/user-view.spec.ts b/playwright/e2e/user-view/user-view.spec.ts new file mode 100644 index 00000000000..eddc466fece --- /dev/null +++ b/playwright/e2e/user-view/user-view.spec.ts @@ -0,0 +1,35 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("UserView", () => { + test.use({ + displayName: "Violet", + botCreateOpts: { displayName: "Usman" }, + }); + + test("should render the user view as expected", async ({ page, homeserver, user, bot }) => { + await page.goto(`/#/user/${bot.credentials.userId}`); + + 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", { + mask: [page.locator(".mx_UserInfo_profile_mxid")], + }); + }); +}); diff --git a/playwright/e2e/utils.ts b/playwright/e2e/utils.ts new file mode 100644 index 00000000000..30aff64dd8f --- /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 00000000000..a336bd2cfa1 --- /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 00000000000..a5dd856a931 --- /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 50% rename from cypress/e2e/widgets/stickers.spec.ts rename to playwright/e2e/widgets/stickers.spec.ts index 1a172055f97..37aaea58cea 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}/`); - - // Make sure the image points at the sticker image - cy.get(`img[alt="${STICKER_NAME}"]`) - .should("have.attr", "src") - .and("match", /thumbnail\/somewhere\?/); + 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. + 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 00000000000..c8073a34051 --- /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 new file mode 100644 index 00000000000..2317978898d --- /dev/null +++ b/playwright/element-web-test.ts @@ -0,0 +1,358 @@ +/* +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 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"; +import { Credentials, Homeserver, HomeserverInstance, StartHomeserverOpts } from "./plugins/homeserver"; +import { Synapse } from "./plugins/homeserver/synapse"; +import { Dendrite, Pinecone } from "./plugins/homeserver/dendrite"; +import { Instance, MailHogServer } from "./plugins/mailhog"; +import { ElementAppPage } from "./pages/ElementAppPage"; +import { OAuthServer } from "./plugins/oauth_server"; +import { Crypto } from "./pages/crypto"; +import { Toasts } from "./pages/toasts"; +import { Bot, CreateBotOpts } from "./pages/bot"; +import { ProxyInstance, SlidingSyncProxy } from "./plugins/sliding-sync-proxy"; +import { Webserver } from "./plugins/webserver"; + +// Enable experimental service worker support +// See https://playwright.dev/docs/service-workers-experimental#how-to-enable +process.env["PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS"] = "1"; + +const CONFIG_JSON: Partial = { + // This is deliberately quite a minimal config.json, so that we can test that the default settings + // actually work. + // + // The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver. + // We point that to a guaranteed-invalid domain. + default_server_config: { + "m.homeserver": { + base_url: "https://server.invalid", + }, + }, + + // 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", + + features: { + // We don't want to go through the feature announcement during the e2e test + feature_release_announcement: false, + }, +}; + +export type TestOptions = { + cryptoBackend: "legacy" | "rust"; +}; + +interface CredentialsWithDisplayName extends Credentials { + displayName: string; +} + +export const test = base.extend< + TestOptions & { + axe: AxeBuilder; + checkA11y: () => Promise; + + /** + * The contents of the config.json to send when the client requests it. + */ + config: typeof CONFIG_JSON; + + /** + * The options with which to run the {@link #homeserver} fixture. + */ + startHomeserverOpts: StartHomeserverOpts | string; + + homeserver: HomeserverInstance; + oAuthServer: { port: number }; + + /** + * The displayname to use for the user registered in {@link #credentials}. + * + * To set it, call `test.use({ displayName: "myDisplayName" })` in the test file or `describe` block. + * See {@link https://playwright.dev/docs/api/class-test#test-use}. + */ + displayName?: string; + + /** + * A test fixture which registers a test user on the {@link #homeserver} and supplies the details + * of the registered user. + */ + 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; + + /** + * A (rather poorly-named) test fixture which registers a user per {@link #credentials}, stores + * the credentials into localStorage per {@link #homeserver}, and then loads the front page of the + * app. + */ + user: CredentialsWithDisplayName; + + /** + * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`}, + * but wraps the returned `Page` in a class of utilities for interacting with the Element-Web UI, + * {@link ElementAppPage}. + */ + app: ElementAppPage; + + mailhog: { api: mailhog.API; instance: Instance }; + crypto: Crypto; + room?: { roomId: string }; + toasts: Toasts; + 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, 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; + }, {}), + }; + // the default is to use rust now, so set to `false` if on legacy backend + if (cryptoBackend === "legacy") { + json.features.feature_rust_crypto = false; + } + await route.fulfill({ json }); + }); + await use(page); + }, + + startHomeserverOpts: "default", + homeserver: async ({ request, startHomeserverOpts: opts }, use, testInfo) => { + if (typeof opts === "string") { + opts = { template: opts }; + } + + let server: Homeserver; + const homeserverName = process.env["PLAYWRIGHT_HOMESERVER"]; + switch (homeserverName) { + case "dendrite": + server = new Dendrite(request); + break; + case "pinecone": + server = new Pinecone(request); + break; + default: + server = new Synapse(request); + } + + await use(await server.start(opts)); + 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) => { + const server = new OAuthServer(); + const port = server.start(); + await use({ port }); + server.stop(); + }, + + displayName: undefined, + credentials: async ({ homeserver, displayName: testDisplayName }, use) => { + const names = ["Alice", "Bob", "Charlie", "Daniel", "Eve", "Frank", "Grace", "Hannah", "Isaac", "Judy"]; + const password = _.uniqueId("password_"); + const displayName = testDisplayName ?? _.sample(names)!; + + const credentials = await homeserver.registerUser("user", password, displayName); + console.log(`Registered test user @user:localhost with displayname ${displayName}`); + + await use({ + ...credentials, + displayName, + }); + }, + labsFlags: [], + + pageWithCredentials: async ({ page, homeserver, credentials }, use) => { + await page.addInitScript( + ({ baseUrl, credentials }) => { + // Seed the localStorage with the required credentials + window.localStorage.setItem("mx_hs_url", baseUrl); + window.localStorage.setItem("mx_user_id", credentials.userId); + window.localStorage.setItem("mx_access_token", credentials.accessToken); + window.localStorage.setItem("mx_device_id", credentials.deviceId); + window.localStorage.setItem("mx_is_guest", "false"); + window.localStorage.setItem("mx_has_pickle_key", "false"); + window.localStorage.setItem("mx_has_access_token", "true"); + + // Ensure the language is set to a consistent value + window.localStorage.setItem("mx_local_settings", '{"language":"en"}'); + }, + { baseUrl: homeserver.config.baseUrl, credentials }, + ); + await use(page); + }, + + user: async ({ pageWithCredentials: page, credentials }, use) => { + await page.goto("/"); + await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); + await use(credentials); + }, + + axe: async ({ page }, use) => { + await use(new AxeBuilder({ page })); + }, + checkA11y: async ({ axe }, use, testInfo) => + use(async () => { + const results = await axe.analyze(); + + await testInfo.attach("accessibility-scan-results", { + body: JSON.stringify(results, null, 2), + contentType: "application/json", + }); + + expect(results.violations).toEqual([]); + }), + + app: async ({ page }, use) => { + const app = new ElementAppPage(page); + await use(app); + }, + crypto: async ({ page, homeserver, request }, use) => { + await use(new Crypto(page, homeserver, request)); + }, + toasts: async ({ page }, use) => { + await use(new Toasts(page)); + }, + + botCreateOpts: {}, + bot: async ({ page, homeserver, botCreateOpts, user }, use) => { + const bot = new Bot(page, homeserver, botCreateOpts); + await bot.prepareClient(); // eagerly register the bot + await use(bot); + }, + + // eslint-disable-next-line no-empty-pattern + mailhog: async ({}, use) => { + const mailhog = new MailHogServer(); + const instance = await mailhog.start(); + await use(instance); + await mailhog.stop(); + }, + + slidingSyncProxy: async ({ page, user, homeserver }, use) => { + const proxy = new SlidingSyncProxy(homeserver.config.dockerUrl); + const proxyInstance = await proxy.start(); + const proxyAddress = `http://localhost:${proxyInstance.port}`; + await page.addInitScript((proxyAddress) => { + window.localStorage.setItem( + "mx_local_settings", + JSON.stringify({ + feature_sliding_sync_proxy_url: proxyAddress, + }), + ); + window.localStorage.setItem("mx_labs_feature_feature_sliding_sync", "true"); + }, proxyAddress); + await page.goto("/"); + await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); + await use(proxyInstance); + await proxy.stop(); + }, + + // eslint-disable-next-line no-empty-pattern + webserver: async ({}, use) => { + const webserver = new Webserver(); + await use(webserver); + webserver.stop(); + }, +}); + +export const expect = baseExpect.extend({ + 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 + const style = (await page.addStyleTag({ + content: ` + .mx_MessagePanel_myReadMarker { + display: none !important; + } + .mx_RoomView_MessageList { + height: auto !important; + } + .mx_DisambiguatedProfile_displayName { + color: var(--cpd-color-blue-1200) !important; + } + .mx_BaseAvatar { + background-color: var(--cpd-color-fuchsia-1200) !important; + color: white !important; + } + .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(name, options); + + await style.evaluate((tag) => tag.remove()); + return { pass: true, message: () => "", name: "toMatchScreenshot" }; + }, +}); diff --git a/playwright/flaky-reporter.ts b/playwright/flaky-reporter.ts new file mode 100644 index 00000000000..3d358bb74d1 --- /dev/null +++ b/playwright/flaky-reporter.ts @@ -0,0 +1,85 @@ +/* +Copyright 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. +*/ + +/** + * Flaky test reporter, creating & updating GitHub issues + * Only intended to run from within GitHub Actions + */ + +import type { Reporter, TestCase } from "@playwright/test/reporter"; + +const REPO = "element-hq/element-web"; +const LABEL = "Z-Flaky-Test"; +const ISSUE_TITLE_PREFIX = "Flaky playwright test: "; + +class FlakyReporter implements Reporter { + private flakes = new Set(); + + public onTestEnd(test: TestCase): void { + const title = `${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`; + if (test.outcome() === "flaky") { + this.flakes.add(title); + } + } + + public async onExit(): Promise { + if (this.flakes.size === 0) { + console.log("No flakes found"); + return; + } + + console.log("Found flakes: "); + for (const flake of this.flakes) { + console.log(flake); + } + + const { GITHUB_TOKEN, GITHUB_API_URL, GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env; + if (!GITHUB_TOKEN) return; + + const body = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`; + + const headers = { Authorization: `Bearer ${GITHUB_TOKEN}` }; + // Fetch all existing issues with the flaky-test label. + const issuesRequest = await fetch(`${GITHUB_API_URL}/repos/${REPO}/issues?labels=${LABEL}`, { headers }); + const issues = await issuesRequest.json(); + for (const flake of this.flakes) { + const title = ISSUE_TITLE_PREFIX + "`" + flake + "`"; + const existingIssue = issues.find((issue) => issue.title === title); + + if (existingIssue) { + console.log(`Found issue ${existingIssue.number} for ${flake}, adding comment...`); + await fetch(`${existingIssue.url}/comments`, { + method: "POST", + headers, + body: JSON.stringify({ body }), + }); + } else { + console.log(`Creating new issue for ${flake}...`); + await fetch(`${GITHUB_API_URL}/repos/${REPO}/issues`, { + method: "POST", + headers, + body: JSON.stringify({ + title, + body, + labels: [LABEL], + }), + }); + } + } + } +} + +export default FlakyReporter; diff --git a/playwright/global.d.ts b/playwright/global.d.ts new file mode 100644 index 00000000000..9663b9310fe --- /dev/null +++ b/playwright/global.d.ts @@ -0,0 +1,40 @@ +/* +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 * as Matrix from "matrix-js-sdk/src/matrix"; +import { type SettingLevel } from "../src/settings/SettingLevel"; + +declare global { + interface Window { + mxMatrixClientPeg: { + get(): Matrix.MatrixClient; + }; + 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; + } +} + +// Workaround for lack of strict mode not resolving complex types correctly +declare module "matrix-js-sdk/src/http-api/index.ts" { + interface UploadResponse { + json(): Promise; + } +} diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts new file mode 100644 index 00000000000..ac9b4ffef80 --- /dev/null +++ b/playwright/pages/ElementAppPage.ts @@ -0,0 +1,174 @@ +/* +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, type Page, expect } from "@playwright/test"; + +import { Settings } from "./settings"; +import { Client } from "./client"; +import { Timeline } from "./timeline"; +import { Spotlight } from "./Spotlight"; + +/** + * A set of utility methods for interacting with the Element-Web UI. + */ +export class ElementAppPage { + public constructor(public readonly page: Page) {} + + // We create these lazily on first access to avoid calling setup code which might cause conflicts, + // e.g. the network routing code in the client subfixture. + private _settings?: Settings; + public get settings(): Settings { + if (!this._settings) this._settings = new Settings(this.page); + return this._settings; + } + private _client?: Client; + public get client(): Client { + if (!this._client) this._client = new Client(this.page); + return this._client; + } + private _timeline?: Timeline; + public get timeline(): Timeline { + if (!this._timeline) this._timeline = new Timeline(this.page); + return this._timeline; + } + + /** + * Open the top left user menu, returning a Locator to the resulting context menu. + */ + public async openUserMenu(): Promise { + return this.settings.openUserMenu(); + } + + /** + * Open room creation dialog. + */ + public async openCreateRoomDialog(): Promise { + await this.page.getByRole("button", { name: "Add room", exact: true }).click(); + await this.page.getByRole("menuitem", { name: "New room", exact: true }).click(); + return this.page.locator(".mx_CreateRoomDialog"); + } + + /** + * Close dialog currently open dialog + */ + public async closeDialog(): Promise { + return this.settings.closeDialog(); + } + + public async getClipboard(): Promise { + return await this.page.evaluate(() => navigator.clipboard.readText()); + } + + /** + * 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 + * room may contain unread messages. + * + * @param name The exact room name to find and click on/open. + */ + public async viewRoomByName(name: string): Promise { + // We look for the room inside the room list, which is a tree called Rooms. + // + // There are 3 cases: + // - the room list is folded: + // then the aria-label on the room tile is the name (with nothing extra) + // - the room list is unfolder and the room has messages: + // then the aria-label contains the unread count, but the title of the + // div inside the titleContainer equals the room name + // - the room list is unfolded and the room has no messages: + // then the aria-label is the name and so is the title of a div + // + // So by matching EITHER title=name OR aria-label=name we find this exact + // room in all three cases. + return this.page + .getByRole("tree", { name: "Rooms" }) + .locator(`[title="${name}"],[aria-label="${name}"]`) + .first() + .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 + */ + public getComposer(isRightPanel?: boolean): Locator { + const panelClass = isRightPanel ? ".mx_RightPanel" : ".mx_RoomView_body"; + 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 + */ + public async openMessageComposerOptions(isRightPanel?: boolean): Promise { + const composer = this.getComposer(isRightPanel); + await composer.getByRole("button", { name: "More options", exact: true }).click(); + return this.page.getByRole("menu"); + } + + /** + * Returns the space panel space button based on a name. The space + * must be visible in the space panel + * @param name The space name to find + */ + public async getSpacePanelButton(name: string): Promise { + const button = this.page.getByRole("button", { name: name }); + await expect(button).toHaveClass(/mx_SpaceButton/); + return button; + } + + /** + * Opens the given space home by name. The space must be visible in + * the space list. + * @param name The space name to find and click on/open. + */ + public async viewSpaceHomeByName(name: string): Promise { + const button = await this.getSpacePanelButton(name); + return button.dblclick(); + } + + /** + * Opens the given space by name. The space must be visible in the + * space list. + * @param name The space name to find and click on/open. + */ + public async viewSpaceByName(name: string): Promise { + const button = await this.getSpacePanelButton(name); + return button.click(); + } + + 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 00000000000..dcd4b73f85a --- /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 new file mode 100644 index 00000000000..333d895dfe9 --- /dev/null +++ b/playwright/pages/bot.ts @@ -0,0 +1,240 @@ +/* +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 { JSHandle, Page } from "@playwright/test"; +import { uniqueId } from "lodash"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; + +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 { + /** + * A prefix to use for the userid. If unspecified, "bot_" will be used. + */ + userIdPrefix?: string; + /** + * Whether the bot should automatically accept all invites. + */ + autoAcceptInvites?: boolean; + /** + * The display name to give to that bot user + */ + displayName?: string; + /** + * Whether to start the syncing client. + */ + startClient?: boolean; + /** + * Whether to generate cross-signing keys + */ + bootstrapCrossSigning?: boolean; + /** + * Whether to use the rust crypto impl. Defaults to false (for now!) + */ + rustCrypto?: boolean; + /** + * Whether to bootstrap the secret storage + */ + bootstrapSecretStorage?: boolean; +} + +const defaultCreateBotOptions = { + userIdPrefix: "bot_", + autoAcceptInvites: true, + startClient: true, + 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, + ) { + 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 + const username = + this.opts.userIdPrefix + + uniqueId(this.opts.userIdPrefix) + .substring(this.opts.userIdPrefix?.length ?? 0) + .padStart(4, "0"); + const password = uniqueId("password_"); + console.log(`getBot: Create bot user ${username} with opts ${JSON.stringify(this.opts)}`); + this.credentials = await this.homeserver.registerUser(username, password, this.opts.displayName); + return this.credentials; + } + + 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) => { + return keys[type]; + }; + + const saveCrossSigningKeys = (k: Record) => { + Object.assign(keys, k); + }; + + // Store the cached secret storage key and return it when `getSecretStorageKey` is called + let cachedKey: { keyId: string; key: Uint8Array }; + const cacheSecretStorageKey = ( + keyId: string, + keyInfo: SecretStorageKeyDescription, + key: Uint8Array, + ) => { + cachedKey = { + keyId, + key, + }; + }; + + const getSecretStorageKey = () => + Promise.resolve<[string, Uint8Array]>([cachedKey.keyId, cachedKey.key]); + + const cryptoCallbacks = { + getCrossSigningKey, + saveCrossSigningKeys, + cacheSecretStorageKey, + getSecretStorageKey, + }; + + const cli = new window.matrixcs.MatrixClient({ + baseUrl: homeserver.baseUrl, + userId: credentials.userId, + deviceId: credentials.deviceId, + accessToken: credentials.accessToken, + store: new window.matrixcs.MemoryStore(), + 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) => { + if (member.membership === "invite" && member.userId === cli.getUserId()) { + cli.joinRoom(member.roomId); + } + }); + } + + if (!opts.startClient) { + return cli; + } + + if (opts.rustCrypto) { + await cli.initRustCrypto({ useIndexedDB: false }); + } else { + await cli.initCrypto(); + } + cli.setGlobalErrorOnUnknownDevices(false); + 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({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: credentials.userId, + }, + password: credentials.password, + }); + }, + }); + } + + if (opts.bootstrapSecretStorage) { + const passphrase = "new passphrase"; + const recoveryKey = await cli.getCrypto().createRecoveryKeyFromPassphrase(passphrase); + Object.assign(cli, { __playwright_recovery_key: recoveryKey }); + + await cli.getCrypto()!.bootstrapSecretStorage({ + setupNewSecretStorage: true, + setupNewKeyBackup: true, + createSecretStorageKey: () => Promise.resolve(recoveryKey), + }); + } + + return cli; + }, + { + homeserver: this.homeserver.config, + credentials: await this.getCredentials(), + opts: this.opts, + }, + ); + return this.handlePromise; + } +} diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts new file mode 100644 index 00000000000..94ee5d88130 --- /dev/null +++ b/playwright/pages/client.ts @@ -0,0 +1,449 @@ +/* +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 { JSHandle, Page } from "@playwright/test"; +import { PageFunctionOn } from "playwright-core/types/structs"; + +import { Network } from "./network"; +import type { + IContent, + ICreateRoomOpts, + ISendEventResponse, + MatrixClient, + Room, + MatrixEvent, + ReceiptType, + IRoomDirectoryOptions, + KnockRoomOpts, + Visibility, + UploadOpts, + Upload, + StateEvents, + TimelineEvents, +} from "matrix-js-sdk/src/matrix"; +import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; +import { Credentials } from "../plugins/homeserver"; + +export class Client { + public network: Network; + protected client: JSHandle; + + protected getClientHandle(): Promise> { + return this.page.evaluateHandle(() => window.mxMatrixClientPeg.get()); + } + + public async prepareClient(): Promise> { + if (!this.client) { + this.client = await this.getClientHandle(); + } + return this.client; + } + + public constructor(protected readonly page: Page) { + page.on("framenavigated", async () => { + this.client = null; + }); + this.network = new Network(page, this); + } + + public evaluate( + pageFunction: PageFunctionOn, + arg: Arg, + ): Promise; + public evaluate( + pageFunction: PageFunctionOn, + arg?: any, + ): Promise; + public async evaluate(fn: (client: MatrixClient) => T, arg?: any): Promise { + await this.prepareClient(); + return this.client.evaluate(fn, arg); + } + + public evaluateHandle( + pageFunction: PageFunctionOn, + arg: Arg, + ): Promise>; + public evaluateHandle( + pageFunction: PageFunctionOn, + arg?: any, + ): Promise>; + public async evaluateHandle(fn: (client: MatrixClient) => T, arg?: any): Promise> { + await this.prepareClient(); + return this.client.evaluateHandle(fn, arg); + } + + /** + * @param roomId ID of the room to send the event into + * @param threadId ID of the thread to send into or null for main timeline + * @param eventType type of event to send + * @param content the event content to send + */ + public async sendEvent( + roomId: string, + threadId: string | null, + eventType: string, + content: IContent, + ): Promise { + const client = await this.prepareClient(); + return client.evaluate( + async (client, { roomId, threadId, eventType, content }) => { + return client.sendEvent( + roomId, + threadId, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents], + ); + }, + { roomId, threadId, eventType, content }, + ); + } + + /** + * 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 | 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, threadId }) => { + return client.sendMessage(roomId, threadId, content as RoomMessageEventContent); + }, + { + roomId, + content, + threadId, + }, + ); + } + + public async redactEvent(roomId: string, eventId: string, reason?: string): Promise { + return this.evaluate( + async (client, { roomId, eventId, reason }) => { + return client.redactEvent(roomId, eventId, reason); + }, + { roomId, eventId, reason }, + ); + } + + /** + * Create a room with given options. + * @param options the options to apply when creating the room + * @return the ID of the newly created room + */ + public async createRoom(options: ICreateRoomOpts): Promise { + const client = await this.prepareClient(); + return await client.evaluate(async (cli, options) => { + const resp = await cli.createRoom(options); + const roomId = resp.room_id; + if (!cli.getRoom(roomId)) { + await new Promise((resolve) => { + const onRoom = (room: Room) => { + if (room.roomId === roomId) { + cli.off(window.matrixcs.ClientEvent.Room, onRoom); + resolve(); + } + }; + cli.on(window.matrixcs.ClientEvent.Room, onRoom); + }); + } + return roomId; + }, options); + } + + /** + * Create a space with given options. + * @param options the options to apply when creating the space + * @return the ID of the newly created space (room) + */ + public async createSpace(options: ICreateRoomOpts): Promise { + return this.createRoom({ + ...options, + creation_content: { + ...options.creation_content, + type: "m.space", + }, + }); + } + + /** + * Joins the given room by alias or ID + * @param roomIdOrAlias the id or alias of the room to join + */ + public async joinRoom(roomIdOrAlias: string): Promise { + const client = await this.prepareClient(); + await client.evaluate(async (client, roomIdOrAlias) => { + return await client.joinRoom(roomIdOrAlias); + }, roomIdOrAlias); + } + + /** + * Make this bot join a room by name + * @param roomName Name of the room to join + */ + public async joinRoomByName(roomName: string): Promise { + const client = await this.prepareClient(); + return client.evaluate( + async (client, { roomName }) => { + const room = client.getRooms().find((r) => r.getDefaultRoomName(client.getUserId()) === roomName); + if (room) { + await client.joinRoom(room.roomId); + return room.roomId; + } + throw new Error(`Bot room join failed. Cannot find room '${roomName}'`); + }, + { + roomName, + }, + ); + } + + /** + * 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 + * @param userId the id of the user to invite + */ + public async inviteUser(roomId: string, userId: string): Promise { + const client = await this.prepareClient(); + await client.evaluate((client, { roomId, userId }) => client.invite(roomId, userId), { + roomId, + userId, + }); + } + + /** + * 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 + * @param {boolean} unthreaded + */ + public async sendReadReceipt( + event: JSHandle, + receiptType?: ReceiptType, + unthreaded?: boolean, + ): Promise<{}> { + const client = await this.prepareClient(); + return client.evaluate( + (client, { event, receiptType, unthreaded }) => { + return client.sendReadReceipt(event, receiptType, unthreaded); + }, + { event, receiptType, unthreaded }, + ); + } + + public async publicRooms(options?: IRoomDirectoryOptions): ReturnType { + const client = await this.prepareClient(); + 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 as keyof StateEvents, 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/crypto.ts b/playwright/pages/crypto.ts new file mode 100644 index 00000000000..183f5629e80 --- /dev/null +++ b/playwright/pages/crypto.ts @@ -0,0 +1,57 @@ +/* +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 { APIRequestContext, Page, expect } from "@playwright/test"; + +import { HomeserverInstance } from "../plugins/homeserver"; + +export class Crypto { + public constructor( + private page: Page, + private homeserver: HomeserverInstance, + private request: APIRequestContext, + ) {} + + /** + * Check that the user has published cross-signing keys, and that the user's device has been cross-signed. + */ + public async assertDeviceIsCrossSigned(): Promise { + const { userId, deviceId, accessToken } = await this.page.evaluate(() => ({ + userId: window.mxMatrixClientPeg.get().getUserId(), + deviceId: window.mxMatrixClientPeg.get().getDeviceId(), + accessToken: window.mxMatrixClientPeg.get().getAccessToken(), + })); + + const res = await this.request.post(`${this.homeserver.config.baseUrl}/_matrix/client/v3/keys/query`, { + headers: { Authorization: `Bearer ${accessToken}` }, + data: { device_keys: { [userId]: [] } }, + }); + const json = await res.json(); + + // there should be three cross-signing keys + expect(json.master_keys[userId]).toHaveProperty("keys"); + expect(json.self_signing_keys[userId]).toHaveProperty("keys"); + expect(json.user_signing_keys[userId]).toHaveProperty("keys"); + + // and the device should be signed by the self-signing key + const selfSigningKeyId = Object.keys(json.self_signing_keys[userId].keys)[0]; + + expect(json.device_keys[userId][deviceId]).toBeDefined(); + + const myDeviceSignatures = json.device_keys[userId][deviceId].signatures[userId]; + expect(myDeviceSignatures[selfSigningKeyId]).toBeDefined(); + } +} diff --git a/playwright/pages/network.ts b/playwright/pages/network.ts new file mode 100644 index 00000000000..f53c32ad99a --- /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 new file mode 100644 index 00000000000..c0efb6770c8 --- /dev/null +++ b/playwright/pages/settings.ts @@ -0,0 +1,103 @@ +/* +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 { Locator, Page } from "@playwright/test"; + +import type { SettingLevel } from "../../src/settings/SettingLevel"; + +export class Settings { + public constructor(private readonly page: Page) {} + + /** + * Open the top left user menu, returning a Locator to the resulting context menu. + */ + public async openUserMenu(): Promise { + const locator = this.page.locator(".mx_ContextualMenu"); + if (await locator.locator(".mx_UserMenu_contextMenu_header").isVisible()) return locator; + await this.page.getByRole("button", { name: "User menu" }).click(); + await locator.waitFor(); + return locator; + } + + /** + * Close dialog currently open dialog + */ + public async closeDialog(): Promise { + return this.page.getByRole("button", { name: "Close dialog", exact: true }).click(); + } + + /** + * Sets the value for a setting. The room ID is optional if the + * setting is not being set for a particular room, otherwise it + * should be supplied. The value may be null to indicate that the + * level should no longer have an override. + * @param {string} settingName The name of the setting to change. + * @param {String} roomId The room ID to change the value in, may be + * null. + * @param {SettingLevel} level The level to change the value at. + * @param {*} value The new value of the setting, may be null. + * @return {Promise} Resolves when the setting has been changed. + */ + public async setValue(settingName: string, roomId: string, level: SettingLevel, value: any): Promise { + return this.page.evaluate< + Promise, + { + settingName: string; + roomId: string | null; + level: SettingLevel; + value: any; + } + >( + ({ settingName, roomId, level, value }) => { + return window.mxSettingsStore.setValue(settingName, roomId, level, value); + }, + { settingName, roomId, level, value }, + ); + } + + /** + * Switch settings tab to the one by the given name + * @param tab the name of the tab to switch to. + */ + public async switchTab(tab: string): Promise { + await this.page + .locator(".mx_TabbedView_tabLabels") + .locator(".mx_TabbedView_tabLabel", { hasText: tab }) + .click(); + } + + /** + * Open user settings (via user menu), returns a locator to the dialog + * @param tab the name of the tab to switch to after opening, optional. + */ + public async openUserSettings(tab?: string): Promise { + const locator = await this.openUserMenu(); + await locator.getByRole("menuitem", { name: "All settings", exact: true }).click(); + if (tab) await this.switchTab(tab); + return this.page.locator(".mx_Dialog").filter({ has: this.page.locator(".mx_UserSettingsDialog") }); + } + + /** + * 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("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 00000000000..de9a9a58ec8 --- /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/pages/toasts.ts b/playwright/pages/toasts.ts new file mode 100644 index 00000000000..0785f33c23d --- /dev/null +++ b/playwright/pages/toasts.ts @@ -0,0 +1,60 @@ +/* +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, expect, Locator } from "@playwright/test"; + +export class Toasts { + public constructor(private readonly page: Page) {} + + /** + * Assert that a toast with the given title exists, and return it + * + * @param expectedTitle - Expected title of the toast + * @returns the Locator for the matching toast + */ + public async getToast(expectedTitle: string): Promise { + const toast = this.page.locator(".mx_Toast_toast", { hasText: expectedTitle }).first(); + await expect(toast).toBeVisible(); + return toast; + } + + /** + * Assert that no toasts exist + */ + public async assertNoToasts(): Promise { + await expect(this.page.locator(".mx_Toast_toast")).not.toBeVisible(); + } + + /** + * Accept a toast with the given title, only works for the first toast in the stack + * + * @param expectedTitle - Expected title of the toast + */ + public async acceptToast(expectedTitle: string): Promise { + const toast = await this.getToast(expectedTitle); + await toast.locator(".mx_Toast_buttons .mx_AccessibleButton_kind_primary").click(); + } + + /** + * Reject a toast with the given title, only works for the first toast in the stack + * + * @param expectedTitle - Expected title of the toast + */ + public async rejectToast(expectedTitle: string): Promise { + const toast = await this.getToast(expectedTitle); + await toast.locator(".mx_Toast_buttons .mx_AccessibleButton_kind_danger_outline").click(); + } +} diff --git a/playwright/plugins/docker/index.ts b/playwright/plugins/docker/index.ts new file mode 100644 index 00000000000..2b193c2fbd6 --- /dev/null +++ b/playwright/plugins/docker/index.ts @@ -0,0 +1,155 @@ +/* +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 os from "os"; +import * as crypto from "crypto"; +import * as childProcess from "child_process"; +import * as fse from "fs-extra"; + +/** + * @param cmd - command to execute + * @param args - arguments to pass to executed command + * @param suppressOutput - whether to suppress the stdout and stderr resulting from this command. + * @return Promise which resolves to an object containing the string value of what was + * written to stdout and stderr by the executed command. + */ +const exec = (cmd: string, args: string[], suppressOutput = false): Promise<{ stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + if (!suppressOutput) { + const log = ["Running command:", cmd, ...args, "\n"].join(" "); + // When in CI mode we combine reports from multiple runners into a single HTML report + // which has separate files for stdout and stderr, so we print the executed command to both + process.stdout.write(log); + if (process.env.CI) process.stderr.write(log); + } + const { stdout, stderr } = childProcess.execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => { + if (err) reject(err); + resolve({ stdout, stderr }); + if (!suppressOutput) { + process.stdout.write("\n"); + if (process.env.CI) process.stderr.write("\n"); + } + }); + if (!suppressOutput) { + stdout.pipe(process.stdout); + stderr.pipe(process.stderr); + } + }); +}; + +export class Docker { + public id: string; + + async run(opts: { image: string; containerName: string; params?: string[]; cmd?: string[] }): Promise { + const userInfo = os.userInfo(); + const params = opts.params ?? []; + + const isPodman = await Docker.isPodman(); + if (params.includes("-v") && userInfo.uid >= 0) { + // Run the docker container as our uid:gid to prevent problems with permissions. + if (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}`); + } + } + + // Make host.containers.internal work to allow the container to talk to other services via host ports. + if (isPodman) { + params.push("--network"); + params.push("slirp4netns:allow_host_loopback=true"); + } else { + // Docker for Desktop includes a host-gateway mapping on host.docker.internal but to simplify the config + // we use the Podman variant host.containers.internal in all environments. + params.push("--add-host"); + params.push("host.containers.internal:host-gateway"); + } + + // Provided we are not running in CI, add a `--rm` parameter. + // There is no need to remove containers in CI (since they are automatically removed anyway), and + // `--rm` means that if a container crashes this means its logs are wiped out. + if (!process.env.CI) params.unshift("--rm"); + + const args = [ + "run", + "--name", + `${opts.containerName}-${crypto.randomBytes(4).toString("hex")}`, + "-d", + ...params, + opts.image, + ]; + + if (opts.cmd) args.push(...opts.cmd); + + const { stdout } = await exec("docker", args); + this.id = stdout.trim(); + return this.id; + } + + async stop(): Promise { + try { + await exec("docker", ["stop", this.id]); + } catch (err) { + console.error(`Failed to stop docker container`, this.id, err); + } + } + + /** + * @param params - list of parameters to pass to `docker exec` + * @param suppressOutput - whether to suppress the stdout and stderr resulting from this command. + */ + async exec(params: string[], suppressOutput = true): Promise { + await exec("docker", ["exec", this.id, ...params], suppressOutput); + } + + async getContainerIp(): Promise { + const { stdout } = await exec("docker", ["inspect", "-f", "{{ .NetworkSettings.IPAddress }}", this.id]); + return stdout.trim(); + } + + async persistLogsToFile(args: { 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", this.id], { + stdio: ["ignore", stdoutFile, stderrFile], + }) + .once("close", resolve); + }); + if (args.stdoutFile) await fse.close(stdoutFile); + if (args.stderrFile) await fse.close(stderrFile); + } + + /** + * Detects whether the docker command is actually podman. + * To do this, it looks for "podman" in the output of "docker --help". + */ + static async isPodman(): Promise { + const { stdout } = await exec("docker", ["--help"], true); + return stdout.toLowerCase().includes("podman"); + } +} diff --git a/playwright/plugins/homeserver/dendrite/index.ts b/playwright/plugins/homeserver/dendrite/index.ts new file mode 100644 index 00000000000..603bd360a8c --- /dev/null +++ b/playwright/plugins/homeserver/dendrite/index.ts @@ -0,0 +1,157 @@ +/* +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 "node:path"; +import * as os from "node:os"; +import * as fse from "fs-extra"; + +import { getFreePort } from "../../utils/port"; +import { Homeserver, HomeserverConfig, HomeserverInstance, StartHomeserverOpts } from "../"; +import { randB64Bytes } from "../../utils/rand"; +import { Synapse } from "../synapse"; +import { Docker } from "../../docker"; + +const dockerConfigDir = "/etc/dendrite/"; +const dendriteConfigFile = "dendrite.yaml"; + +// Surprisingly, Dendrite implements the same register user Admin API Synapse, so we can just extend it +export class Dendrite extends Synapse implements Homeserver, HomeserverInstance { + public config: HomeserverConfig & { serverId: string }; + protected image = "matrixdotorg/dendrite-monolith:main"; + protected entrypoint = "/usr/bin/dendrite"; + + /** + * Start a dendrite instance: the template must be the name of one of the templates + * in the playwright/plugins/dendritedocker/templates directory + * @param opts + */ + public async start(opts: StartHomeserverOpts): Promise { + const denCfg = await cfgDirFromTemplate(this.image, opts); + + console.log(`Starting dendrite with config dir ${denCfg.configDir}...`); + + const dendriteId = await this.docker.run({ + image: this.image, + params: [ + "-v", + `${denCfg.configDir}:` + dockerConfigDir, + "-p", + `${denCfg.port}:8008/tcp`, + "--entrypoint", + this.entrypoint, + ], + containerName: `react-sdk-playwright-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 this.docker.exec([ + "curl", + "--connect-timeout", + "30", + "--retry", + "30", + "--retry-delay", + "1", + "--retry-all-errors", + "--silent", + "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 { + 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); + await fse.ensureDir(dendriteLogsPath); + + await this.docker.persistLogsToFile({ + stdoutFile: path.join(dendriteLogsPath, "stdout.log"), + stderrFile: path.join(dendriteLogsPath, "stderr.log"), + }); + + await this.docker.stop(); + + 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")]; + } +} + +export class Pinecone extends Dendrite { + protected image = "matrixdotorg/dendrite-demo-pinecone:main"; + protected entrypoint = "/usr/bin/dendrite-demo-pinecone"; +} + +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); + + 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); + + const docker = new Docker(); + await docker.run({ + image: dendriteImage, + params: ["--entrypoint=", "-v", `${tempDir}:/mnt`], + containerName: `react-sdk-playwright-dendrite-keygen`, + cmd: ["/usr/bin/generate-keys", "-private-key", "/mnt/matrix_key.pem"], + }); + + return { + port, + baseUrl, + configDir: tempDir, + registrationSecret, + }; +} + +export function isDendrite(): boolean { + return process.env["PLAYWRIGHT_HOMESERVER"] === "dendrite" || process.env["PLAYWRIGHT_HOMESERVER"] === "pinecone"; +} diff --git a/cypress/plugins/dendritedocker/templates/default/dendrite.yaml b/playwright/plugins/homeserver/dendrite/templates/default/dendrite.yaml similarity index 100% rename from cypress/plugins/dendritedocker/templates/default/dendrite.yaml rename to playwright/plugins/homeserver/dendrite/templates/default/dendrite.yaml diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts new file mode 100644 index 00000000000..1e0cfb3b39c --- /dev/null +++ b/playwright/plugins/homeserver/index.ts @@ -0,0 +1,72 @@ +/* +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. +*/ + +export interface HomeserverConfig { + readonly configDir: string; + readonly baseUrl: string; + readonly port: number; + readonly registrationSecret: string; + readonly dockerUrl: string; +} + +export interface HomeserverInstance { + readonly config: HomeserverConfig; + + /** + * Register a user on the given Homeserver using the shared registration secret. + * @param username the username of the user to register + * @param password the password of the user to register + * @param displayName optional display name to set on the newly registered user + */ + registerUser(username: string, password: string, displayName?: string): Promise; + + /** + * Logs into synapse with the given username/password + * @param userId login username + * @param password login password + */ + loginUser(userId: string, password: string): Promise; +} + +export interface StartHomeserverOpts { + /** path to template within playwright/plugins/{homeserver}docker/template/ directory. */ + template: string; + + /** Port of an OAuth server to configure the homeserver to use */ + oAuthServerPort?: number; + + /** Additional variables to inject into the configuration template **/ + variables?: Record; +} + +export interface Homeserver { + start(opts: StartHomeserverOpts): 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 { + accessToken: string; + userId: string; + deviceId: string; + homeServer: string; + password: string | null; // null for password-less users + displayName?: string; +} diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts new file mode 100644 index 00000000000..c88fd641d9d --- /dev/null +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -0,0 +1,210 @@ +/* +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 "node:path"; +import * as os from "node:os"; +import * as crypto from "node:crypto"; +import * as fse from "fs-extra"; +import { APIRequestContext } from "@playwright/test"; + +import { getFreePort } from "../../utils/port"; +import { Docker } from "../../docker"; +import { HomeserverConfig, HomeserverInstance, Homeserver, StartHomeserverOpts, Credentials } from ".."; +import { randB64Bytes } from "../../utils/rand"; + +// Docker tag to use for `matrixdotorg/synapse` image. +// We target a specific digest as every now and then a Synapse update will break our CI. +// This digest is updated by the playwright-image-updates.yaml workflow periodically. +const DOCKER_TAG = "develop@sha256:c357ea1486c8cd2613932e97bd2f6ff4e8b4c4fafcb2c28d1e8ec0d383c56d9d"; + +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); + if (opts.oAuthServerPort) { + hsYaml = hsYaml.replace(/{{OAUTH_SERVER_PORT}}/g, opts.oAuthServerPort.toString()); + } + if (opts.variables) { + for (const key in opts.variables) { + hsYaml = hsYaml.replace(new RegExp("%" + key + "%", "g"), String(opts.variables[key])); + } + } + + 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}`); + + // 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, + configDir: tempDir, + registrationSecret, + }; +} + +export class Synapse implements Homeserver, HomeserverInstance { + protected docker: Docker = new Docker(); + public config: HomeserverConfig & { serverId: string }; + + public constructor(private readonly request: APIRequestContext) {} + + /** + * Start a synapse instance: the template must be the name of + * one of the templates in the playwright/plugins/synapsedocker/templates + * directory. + */ + public async start(opts: StartHomeserverOpts): Promise { + if (this.config) await this.stop(); + + const synCfg = await cfgDirFromTemplate(opts); + console.log(`Starting synapse with config dir ${synCfg.configDir}...`); + const dockerSynapseParams = ["-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`]; + const synapseId = await this.docker.run({ + image: `matrixdotorg/synapse:${DOCKER_TAG}`, + containerName: `react-sdk-playwright-synapse`, + params: dockerSynapseParams, + cmd: ["run"], + }); + console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); + // Await Synapse healthcheck + await this.docker.exec([ + "curl", + "--connect-timeout", + "30", + "--retry", + "30", + "--retry-delay", + "1", + "--retry-all-errors", + "--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 { + 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", "logs", "synapse", id); + await fse.ensureDir(synapseLogsPath); + await this.docker.persistLogsToFile({ + stdoutFile: path.join(synapseLogsPath, "stdout.log"), + stderrFile: path.join(synapseLogsPath, "stderr.log"), + }); + 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 { + const url = `${this.config.baseUrl}/_synapse/admin/v1/register`; + const { nonce } = await this.request.get(url).then((r) => r.json()); + const mac = crypto + .createHmac("sha1", this.config.registrationSecret) + .update(`${nonce}\0${username}\0${password}\0notadmin`) + .digest("hex"); + const res = await this.request.post(url, { + data: { + nonce, + username, + password, + mac, + admin: false, + displayname: displayName, + }, + }); + + if (!res.ok()) { + throw await res.json(); + } + + const data = await res.json(); + return { + homeServer: data.home_server, + accessToken: data.access_token, + userId: data.user_id, + deviceId: data.device_id, + password, + displayName, + }; + } + + public async loginUser(userId: string, password: string): Promise { + const url = `${this.config.baseUrl}/_matrix/client/v3/login`; + const res = await this.request.post(url, { + data: { + type: "m.login.password", + identifier: { + type: "m.id.user", + user: userId, + }, + password: password, + }, + }); + const json = await res.json(); + + return { + password, + accessToken: json.access_token, + userId: json.user_id, + deviceId: json.device_id, + homeServer: json.home_server, + }; + } +} diff --git a/cypress/plugins/synapsedocker/templates/COPYME/README.md b/playwright/plugins/homeserver/synapse/templates/COPYME/README.md similarity index 100% rename from cypress/plugins/synapsedocker/templates/COPYME/README.md rename to playwright/plugins/homeserver/synapse/templates/COPYME/README.md diff --git a/cypress/plugins/synapsedocker/templates/COPYME/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/COPYME/homeserver.yaml similarity index 100% rename from cypress/plugins/synapsedocker/templates/COPYME/homeserver.yaml rename to playwright/plugins/homeserver/synapse/templates/COPYME/homeserver.yaml diff --git a/cypress/plugins/synapsedocker/templates/COPYME/log.config b/playwright/plugins/homeserver/synapse/templates/COPYME/log.config similarity index 100% rename from cypress/plugins/synapsedocker/templates/COPYME/log.config rename to playwright/plugins/homeserver/synapse/templates/COPYME/log.config diff --git a/cypress/plugins/synapsedocker/templates/consent/README.md b/playwright/plugins/homeserver/synapse/templates/consent/README.md similarity index 100% rename from cypress/plugins/synapsedocker/templates/consent/README.md rename to playwright/plugins/homeserver/synapse/templates/consent/README.md diff --git a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/consent/homeserver.yaml similarity index 100% rename from cypress/plugins/synapsedocker/templates/consent/homeserver.yaml rename to playwright/plugins/homeserver/synapse/templates/consent/homeserver.yaml diff --git a/cypress/plugins/synapsedocker/templates/consent/log.config b/playwright/plugins/homeserver/synapse/templates/consent/log.config similarity index 100% rename from cypress/plugins/synapsedocker/templates/consent/log.config rename to playwright/plugins/homeserver/synapse/templates/consent/log.config diff --git a/cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/1.0.html b/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/1.0.html similarity index 97% rename from cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/1.0.html rename to playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/1.0.html index 8ee888518ab..bcc7a590bb2 100644 --- a/cypress/plugins/synapsedocker/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/cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/success.html b/playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/success.html similarity index 89% rename from cypress/plugins/synapsedocker/templates/consent/res/templates/privacy/en/success.html rename to playwright/plugins/homeserver/synapse/templates/consent/res/templates/privacy/en/success.html index 8db01e8a6e7..2a2b21eef4e 100644 --- a/cypress/plugins/synapsedocker/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/cypress/plugins/synapsedocker/templates/default/README.md b/playwright/plugins/homeserver/synapse/templates/default/README.md similarity index 100% rename from cypress/plugins/synapsedocker/templates/default/README.md rename to playwright/plugins/homeserver/synapse/templates/default/README.md diff --git a/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml new file mode 100644 index 00000000000..bc3ecd7c9b5 --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml @@ -0,0 +1,104 @@ +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. + token_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/token" + userinfo_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/userinfo" + client_id: "synapse" + 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 + +experimental_features: + # Needed for e2e/crypto/crypto.spec.ts > Cryptography > decryption failure + # messages > non-joined historical messages. + # Can be removed after Synapse enables it by default + msc4115_membership_on_events: true diff --git a/cypress/plugins/synapsedocker/templates/default/log.config b/playwright/plugins/homeserver/synapse/templates/default/log.config similarity index 100% rename from cypress/plugins/synapsedocker/templates/default/log.config rename to playwright/plugins/homeserver/synapse/templates/default/log.config diff --git a/playwright/plugins/homeserver/synapse/templates/dehydration/README.md b/playwright/plugins/homeserver/synapse/templates/dehydration/README.md new file mode 100644 index 00000000000..18f7923e6d2 --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/dehydration/README.md @@ -0,0 +1 @@ +A synapse configured with device dehydration v2 enabled diff --git a/cypress/plugins/synapsedocker/templates/default/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/dehydration/homeserver.yaml similarity index 62% rename from cypress/plugins/synapsedocker/templates/default/homeserver.yaml rename to playwright/plugins/homeserver/synapse/templates/dehydration/homeserver.yaml index aaad3420b99..c3ac5d6536c 100644 --- a/cypress/plugins/synapsedocker/templates/default/homeserver.yaml +++ b/playwright/plugins/homeserver/synapse/templates/dehydration/homeserver.yaml @@ -74,3 +74,29 @@ 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. + token_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/token" + userinfo_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/userinfo" + client_id: "synapse" + 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 + +experimental_features: + msc2697_enabled: false + msc3814_enabled: true diff --git a/playwright/plugins/homeserver/synapse/templates/dehydration/log.config b/playwright/plugins/homeserver/synapse/templates/dehydration/log.config new file mode 100644 index 00000000000..b9123d0f5b9 --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/dehydration/log.config @@ -0,0 +1,50 @@ +# 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/playwright/plugins/homeserver/synapse/templates/email/README.md b/playwright/plugins/homeserver/synapse/templates/email/README.md new file mode 100644 index 00000000000..40c23ba0be4 --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/email/README.md @@ -0,0 +1 @@ +A synapse configured to require an email for registration diff --git a/playwright/plugins/homeserver/synapse/templates/email/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/email/homeserver.yaml new file mode 100644 index 00000000000..fc20641ab40 --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/email/homeserver.yaml @@ -0,0 +1,44 @@ +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/playwright/plugins/homeserver/synapse/templates/email/log.config b/playwright/plugins/homeserver/synapse/templates/email/log.config new file mode 100644 index 00000000000..ac232762da3 --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/email/log.config @@ -0,0 +1,50 @@ +# 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/playwright/plugins/homeserver/synapse/templates/mas-oidc/README.md b/playwright/plugins/homeserver/synapse/templates/mas-oidc/README.md new file mode 100644 index 00000000000..223ff436a8d --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/mas-oidc/README.md @@ -0,0 +1 @@ +A synapse configured with auth delegated to via matrix authentication service diff --git a/playwright/plugins/homeserver/synapse/templates/mas-oidc/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/mas-oidc/homeserver.yaml new file mode 100644 index 00000000000..802d97acade --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/mas-oidc/homeserver.yaml @@ -0,0 +1,194 @@ +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" +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" + +# Inhibit background updates as this Synapse isn't long-lived +background_updates: + min_batch_size: 100000 + sleep_duration_ms: 100000 + +serve_server_wellknown: true +experimental_features: + msc3861: + enabled: true + + issuer: http://localhost:%MAS_PORT%/ + # We have to bake in the metadata here as we need to override `introspection_endpoint` + issuer_metadata: { + "issuer": "http://localhost:%MAS_PORT%/", + "authorization_endpoint": "http://localhost:%MAS_PORT%/authorize", + "token_endpoint": "http://localhost:%MAS_PORT%/oauth2/token", + "jwks_uri": "http://localhost:%MAS_PORT%/oauth2/keys.json", + "registration_endpoint": "http://localhost:%MAS_PORT%/oauth2/registration", + "scopes_supported": ["openid", "email"], + "response_types_supported": ["code", "id_token", "code id_token"], + "response_modes_supported": ["form_post", "query", "fragment"], + "grant_types_supported": + [ + "authorization_code", + "refresh_token", + "client_credentials", + "urn:ietf:params:oauth:grant-type:device_code", + ], + "token_endpoint_auth_methods_supported": + ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"], + "token_endpoint_auth_signing_alg_values_supported": + [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES256K", + ], + "revocation_endpoint": "http://localhost:%MAS_PORT%/oauth2/revoke", + "revocation_endpoint_auth_methods_supported": + ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"], + "revocation_endpoint_auth_signing_alg_values_supported": + [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES256K", + ], + # This is the only changed value + "introspection_endpoint": "http://host.containers.internal:%MAS_PORT%/oauth2/introspect", + "introspection_endpoint_auth_methods_supported": + ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"], + "introspection_endpoint_auth_signing_alg_values_supported": + [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES256K", + ], + "code_challenge_methods_supported": ["plain", "S256"], + "userinfo_endpoint": "http://localhost:%MAS_PORT%/oauth2/userinfo", + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": + ["RS256", "RS384", "RS512", "ES256", "ES384", "PS256", "PS384", "PS512", "ES256K"], + "userinfo_signing_alg_values_supported": + ["RS256", "RS384", "RS512", "ES256", "ES384", "PS256", "PS384", "PS512", "ES256K"], + "display_values_supported": ["page"], + "claim_types_supported": ["normal"], + "claims_supported": ["iss", "sub", "aud", "iat", "exp", "nonce", "auth_time", "at_hash", "c_hash"], + "claims_parameter_supported": false, + "request_parameter_supported": false, + "request_uri_parameter_supported": false, + "prompt_values_supported": ["none", "login", "create"], + "device_authorization_endpoint": "http://localhost:%MAS_PORT%/oauth2/device", + "org.matrix.matrix-authentication-service.graphql_endpoint": "http://localhost:%MAS_PORT%/graphql", + "account_management_uri": "http://localhost:%MAS_PORT%/account/", + "account_management_actions_supported": + [ + "org.matrix.profile", + "org.matrix.sessions_list", + "org.matrix.session_view", + "org.matrix.session_end", + ], + } + + # Matches the `client_id` in the auth service config + client_id: 0000000000000000000SYNAPSE + # Matches the `client_auth_method` in the auth service config + client_auth_method: client_secret_basic + # Matches the `client_secret` in the auth service config + client_secret: "SomeRandomSecret" + + # Matches the `matrix.secret` in the auth service config + admin_token: "AnotherRandomSecret" + + # URL to advertise to clients where users can self-manage their account + account_management_url: "http://localhost:%MAS_PORT%/account" diff --git a/playwright/plugins/homeserver/synapse/templates/mas-oidc/log.config b/playwright/plugins/homeserver/synapse/templates/mas-oidc/log.config new file mode 100644 index 00000000000..b9123d0f5b9 --- /dev/null +++ b/playwright/plugins/homeserver/synapse/templates/mas-oidc/log.config @@ -0,0 +1,50 @@ +# 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/playwright/plugins/mailhog/index.ts b/playwright/plugins/mailhog/index.ts new file mode 100644 index 00000000000..684aaee5ed8 --- /dev/null +++ b/playwright/plugins/mailhog/index.ts @@ -0,0 +1,55 @@ +/* +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 mailhog from "mailhog"; + +import { getFreePort } from "../utils/port"; +import { Docker } from "../docker"; + +export interface Instance { + host: string; + smtpPort: number; + httpPort: number; + containerId: string; +} + +export class MailHogServer { + private readonly docker: Docker = new Docker(); + private instance?: Instance; + + async start(): Promise<{ api: mailhog.API; instance: Instance }> { + if (this.instance) throw new Error("Mailhog server is already running!"); + const smtpPort = await getFreePort(); + const httpPort = await getFreePort(); + console.log(`Starting mailhog...`); + const containerId = await this.docker.run({ + image: "mailhog/mailhog:latest", + containerName: `react-sdk-playwright-mailhog`, + params: ["-p", `${smtpPort}:1025/tcp`, "-p", `${httpPort}:8025/tcp`], + }); + console.log(`Started mailhog on ports smtp=${smtpPort} http=${httpPort}.`); + const host = await this.docker.getContainerIp(); + this.instance = { smtpPort, httpPort, containerId, host }; + return { api: mailhog({ host: "localhost", port: httpPort }), instance: this.instance }; + } + + async stop(): Promise { + if (!this.instance) throw new Error("Missing existing mailhog instance, did you call stop() before start()?"); + await this.docker.stop(); + console.log(`Stopped mailhog id ${this.instance.containerId}.`); + this.instance = undefined; + } +} diff --git a/playwright/plugins/matrix-authentication-service/config.yaml b/playwright/plugins/matrix-authentication-service/config.yaml new file mode 100644 index 00000000000..e7ab83e736e --- /dev/null +++ b/playwright/plugins/matrix-authentication-service/config.yaml @@ -0,0 +1,153 @@ +clients: + - client_id: 0000000000000000000SYNAPSE + client_auth_method: client_secret_basic + client_secret: "SomeRandomSecret" +http: + listeners: + - name: web + resources: + - name: discovery + - name: human + - name: oauth + - name: compat + - name: graphql + playground: true + - name: assets + path: /usr/local/share/mas-cli/assets/ + binds: + - address: "[::]:8080" + proxy_protocol: false + - name: internal + resources: + - name: health + binds: + - host: localhost + port: 8081 + proxy_protocol: false + trusted_proxies: + - 192.128.0.0/16 + - 172.16.0.0/12 + - 10.0.0.0/10 + - 127.0.0.1/8 + - fd00::/8 + - ::1/128 + public_base: "http://localhost:{{MAS_PORT}}/" + issuer: http://localhost:{{MAS_PORT}}/ +database: + host: "{{POSTGRES_HOST}}" + port: 5432 + database: postgres + username: postgres + password: "{{POSTGRES_PASSWORD}}" + max_connections: 10 + min_connections: 0 + connect_timeout: 30 + idle_timeout: 600 + max_lifetime: 1800 +telemetry: + tracing: + exporter: none + propagators: [] + metrics: + exporter: none + sentry: + dsn: null +templates: + path: /usr/local/share/mas-cli/templates/ + assets_manifest: /usr/local/share/mas-cli/manifest.json + translations_path: /usr/local/share/mas-cli/translations/ +email: + from: '"Authentication Service" ' + reply_to: '"Authentication Service" ' + transport: smtp + mode: plain + hostname: "host.containers.internal" + port: %{{SMTP_PORT}} + username: username + password: password + +secrets: + encryption: 984b18e207c55ad5fbb2a49b217481a722917ee87b2308d4cf314c83fed8e3b5 + keys: + - kid: YEAhzrKipJ + key: | + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAuIV+AW5vx52I4CuumgSxp6yvKfIAnRdALeZZCoFkIGxUli1B + S79NJ3ls46oLh1pSD9RrhaMp6HTNoi4K3hnP9Q9v77pD7KwdFKG3UdG1zksIB0s/ + +/Ey/DmX4LPluwBBS7r/LkQ1jk745lENA++oiDqZf2D/uP8jCHlvaSNyVKTqi1ki + OXPd4T4xBUjzuas9ze5jQVSYtfOidgnv1EzUipbIxgvH1jNt4raRlmP8mOq7xEnW + R+cF5x6n/g17PdSEfrwO4kz6aKGZuMP5lVlDEEnMHKabFSQDBl7+Mpok6jXutbtA + uiBnsKEahF9eoj4na4fpbRNPdIVyoaN5eGvm5wIDAQABAoIBAApyFCYEmHNWaa83 + CdVSOrRhRDE9r+c0r79pcNT1ajOjrk4qFa4yEC4R46YntCtfY5Hd1pBkIjU0l4d8 + z8Su9WTMEOwjQUEepS7L0NLi6kXZXYT8L40VpGs+32grBvBFHW0qEtQNrHJ36gMv + x2rXoFTF7HaXiSJx3wvVxAbRqOE9tBXLsmNHaWaAdWQG5o77V9+zvMri3cAeEg2w + VkKokb0dza7es7xG3tqS26k69SrwGeeuKo7qCHPH2cfyWmY5Yhv8iOoA59JzzbiK + UdxyzCHskrPSpRKVkVVwmY3RBt282TmSRG7td7e5ESSj50P2e5BI5uu1Hp/dvU4F + vYjV7kECgYEA6WqYoUpVsgQiqhvJwJIc/8gRm0mUy8TenI36z4Iim01Nt7fibWH7 + XnsFqLGjXtYNVWvBcCrUl9doEnRbJeG2eRGbGKYAWVrOeFvwM4fYvw9GoOiJdDj4 + cgWDe7eHbHE+UTqR7Nnr/UBfipoNWDh6X68HRBuXowh0Q6tOfxsrRFECgYEAyl/V + 4b8bFp3pKZZCb+KPSYsQf793cRmrBexPcLWcDPYbMZQADEZ/VLjbrNrpTOWxUWJT + hr8MrWswnHO+l5AFu5CNO+QgV2dHLk+2w8qpdpFRPJCfXfo2D3wZ0c4cv3VCwv1V + 5y7f6XWVjDWZYV4wj6c3shxZJjZ+9Hbhf3/twbcCgYA6fuRRR3fCbRbi2qPtBrEN + yO3gpMgNaQEA6vP4HPzfPrhDWmn8T5nXS61XYW03zxz4U1De81zj0K/cMBzHmZFJ + NghQXQmpWwBzWVcREvJWr1Vb7erEnaJlsMwKrSvbGWYspSj82oAxr3hCG+lMOpsw + b4S6pM+TpAK/EqdRY1WsgQKBgQCGoMaaTRXqL9bC0bEU2XVVCWxKb8c3uEmrwQ7/ + /fD4NmjUzI5TnDps1CVfkqoNe+hAKddDFqmKXHqUOfOaxDbsFje+lf5l5tDVoDYH + fjTKKdYPIm7CiAeauYY7qpA5Vfq52Opixy4yEwUPp0CII67OggFtPaqY3zwJyWQt + +57hdQKBgGCXM/KKt7ceUDcNJxSGjvu0zD9D5Sv2ihYlEBT/JLaTCCJdvzREevaJ + 1d+mpUAt0Lq6A8NWOMq8HPaxAik3rMQ0WtM5iG+XgsUqvTSb7NcshArDLuWGnW3m + MC4rM0UBYAS4QweduUSH1imrwH/1Gu5+PxbiecceRMMggWpzu0Bq + -----END RSA PRIVATE KEY----- + - kid: 8J1AxrlNZT + key: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIF1cjfIOEdy3BXJ72x6fKpEB8WP1ddZAUJAaqqr/6CpToAoGCCqGSM49 + AwEHoUQDQgAEfHdNuI1Yeh3/uOq2PlnW2vymloOVpwBYebbw4VVsna9xhnutIdQW + dE8hkX8Yb0pIDasrDiwllVLzSvsWJAI0Kw== + -----END EC PRIVATE KEY----- + - kid: 3BW6un1EBi + key: | + -----BEGIN EC PRIVATE KEY----- + MIGkAgEBBDA+3ZV17r8TsiMdw1cpbTSNbyEd5SMy3VS1Mk/kz6O2Ev/3QZut8GE2 + q3eGtLBoVQigBwYFK4EEACKhZANiAASs8Wxjk/uRimRKXnPr2/wDaXkN9wMDjYQK + mZULb+0ZP1/cXmuXuri8hUGhQvIU8KWY9PkpV+LMPEdpE54mHPKSLjq5CDXoSZ/P + 9f7cdRaOZ000KQPZfIFR9ujJTtDN7Vs= + -----END EC PRIVATE KEY----- + - kid: pkZ0pTKK0X + key: | + -----BEGIN EC PRIVATE KEY----- + MHQCAQEEIHenfsXYPc5yzjZKUfvmydDR1YRwdsfZYvwHf/2wsYxooAcGBSuBBAAK + oUQDQgAEON1x7Vlu+nA0KvC5vYSOHhDUkfLYNZwYSLPFVT02h9E13yFFMIJegIBl + Aer+6PMZpPc8ycyeH9N+U9NAyliBhQ== + -----END EC PRIVATE KEY----- +passwords: + enabled: true + schemes: + - version: 1 + algorithm: argon2id +matrix: + homeserver: localhost + secret: AnotherRandomSecret + endpoint: "{{SYNAPSE_URL}}" +policy: + wasm_module: /usr/local/share/mas-cli/policy.wasm + client_registration_entrypoint: client_registration/violation + register_entrypoint: register/violation + authorization_grant_entrypoint: authorization_grant/violation + password_entrypoint: password/violation + email_entrypoint: email/violation + data: + client_registration: + allow_insecure_uris: true # allow non-SSL and localhost URIs + allow_missing_contacts: true # EW doesn't have contacts at this time +upstream_oauth2: + providers: [] +branding: + service_name: null + policy_uri: null + tos_uri: null + imprint: null + logo_uri: null +experimental: + access_token_ttl: 300 + compat_token_ttl: 300 diff --git a/playwright/plugins/matrix-authentication-service/index.ts b/playwright/plugins/matrix-authentication-service/index.ts new file mode 100644 index 00000000000..40649159efd --- /dev/null +++ b/playwright/plugins/matrix-authentication-service/index.ts @@ -0,0 +1,159 @@ +/* +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 path, { basename } from "node:path"; +import os from "node:os"; +import * as fse from "fs-extra"; +import { BrowserContext, TestInfo } from "@playwright/test"; + +import { getFreePort } from "../utils/port"; +import { Docker } from "../docker"; +import { PG_PASSWORD, PostgresDocker } from "../postgres"; +import { HomeserverInstance } from "../homeserver"; +import { Instance as MailhogInstance } from "../mailhog"; + +// Docker tag to use for `ghcr.io/matrix-org/matrix-authentication-service` image. +// We use a debug tag so that we have a shell and can run all 3 necessary commands in one run. +const TAG = "0.8.0-debug"; + +export interface ProxyInstance { + containerId: string; + postgresId: string; + configDir: string; + port: number; +} + +async function cfgDirFromTemplate(opts: { + postgresHost: string; + synapseUrl: string; + masPort: string; + smtpPort: string; +}): Promise<{ + configDir: string; +}> { + const configPath = path.join(__dirname, "config.yaml"); + const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), "react-sdk-mas-")); + + const outputHomeserver = path.join(tempDir, "config.yaml"); + console.log(`Gen ${configPath} -> ${outputHomeserver}`); + let config = await fse.readFile(configPath, "utf8"); + config = config.replace(/{{MAS_PORT}}/g, opts.masPort); + config = config.replace(/{{POSTGRES_HOST}}/g, opts.postgresHost); + config = config.replace(/{{POSTGRES_PASSWORD}}/g, PG_PASSWORD); + config = config.replace(/%{{SMTP_PORT}}/g, opts.smtpPort); + config = config.replace(/{{SYNAPSE_URL}}/g, opts.synapseUrl); + + await fse.writeFile(outputHomeserver, config); + + // Allow anyone to read, write and execute in the temp directory + // so that the DIND setup that we use to update the playwright screenshots work without any issues. + await fse.chmod(tempDir, 0o757); + + return { + configDir: tempDir, + }; +} + +export class MatrixAuthenticationService { + private readonly masDocker = new Docker(); + private readonly postgresDocker = new PostgresDocker("mas"); + private instance: ProxyInstance; + public port: number; + + constructor(private context: BrowserContext) {} + + async prepare(): Promise<{ port: number }> { + this.port = await getFreePort(); + return { port: this.port }; + } + + async start(homeserver: HomeserverInstance, mailhog: MailhogInstance): Promise { + console.log(new Date(), "Starting mas..."); + + if (!this.port) await this.prepare(); + const port = this.port; + const { containerId: postgresId, ipAddress: postgresIp } = await this.postgresDocker.start(); + const { configDir } = await cfgDirFromTemplate({ + masPort: port.toString(), + postgresHost: postgresIp, + synapseUrl: homeserver.config.dockerUrl, + smtpPort: mailhog.smtpPort.toString(), + }); + + console.log(new Date(), "starting mas container...", TAG); + const containerId = await this.masDocker.run({ + image: "ghcr.io/matrix-org/matrix-authentication-service:" + TAG, + containerName: "react-sdk-playwright-mas", + params: ["-p", `${port}:8080/tcp`, "-v", `${configDir}:/config`, "--entrypoint", "sh"], + cmd: [ + "-c", + "mas-cli database migrate --config /config/config.yaml && " + + "mas-cli config sync --config /config/config.yaml && " + + "mas-cli server --config /config/config.yaml", + ], + }); + console.log(new Date(), "started!"); + + // Set up redirects + const baseUrl = `http://localhost:${port}`; + for (const path of [ + "**/_matrix/client/*/login", + "**/_matrix/client/*/login/**", + "**/_matrix/client/*/logout", + "**/_matrix/client/*/refresh", + ]) { + await this.context.route(path, async (route) => { + await route.continue({ + url: new URL(route.request().url().split("/").slice(3).join("/"), baseUrl).href, + }); + }); + } + + this.instance = { containerId, postgresId, port, configDir }; + return this.instance; + } + + async stop(testInfo: TestInfo): Promise { + if (!this.instance) return; // nothing to stop + const id = this.instance.containerId; + const logPath = path.join("playwright", "logs", "matrix-authentication-service", id); + await fse.ensureDir(logPath); + await this.masDocker.persistLogsToFile({ + stdoutFile: path.join(logPath, "stdout.log"), + stderrFile: path.join(logPath, "stderr.log"), + }); + + await this.masDocker.stop(); + await this.postgresDocker.stop(); + + if (testInfo.status !== "passed") { + const logs = [path.join(logPath, "stdout.log"), path.join(logPath, "stderr.log")]; + for (const path of logs) { + await testInfo.attach(`mas-${basename(path)}`, { + path, + contentType: "text/plain", + }); + } + await testInfo.attach("mas-config.yaml", { + path: path.join(this.instance.configDir, "config.yaml"), + contentType: "text/plain", + }); + } + + await fse.remove(this.instance.configDir); + console.log(new Date(), "Stopped mas."); + } +} diff --git a/playwright/plugins/oauth_server/README.md b/playwright/plugins/oauth_server/README.md new file mode 100644 index 00000000000..541756384f9 --- /dev/null +++ b/playwright/plugins/oauth_server/README.md @@ -0,0 +1,24 @@ +# oauth_server + +A very simple OAuth identity provider server. + +The following endpoints are exposed: + +- `/oauth/auth.html`: An OAuth2 [authorization endpoint](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint). + In a proper OAuth2 system, this would prompt the user to log in; we just give a big "Submit" button (and an + auth code that can be changed if we want the next step to fail). It redirects back to the calling application + with a "code". + +- `/oauth/token`: An OAuth2 [token endpoint](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint). + Receives the code issued by "auth.html" and, if it is valid, exchanges it for an OAuth2 access token. + +- `/oauth/userinfo`: An OAuth2 [userinfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). + Returns details about the owner of the offered access token. + +To start the server, do: + +```javascript +cy.task("startOAuthServer").then((port) => { + // now we can configure Synapse or Element to talk to the OAuth2 server. +}); +``` diff --git a/playwright/plugins/oauth_server/index.ts b/playwright/plugins/oauth_server/index.ts new file mode 100644 index 00000000000..065436ef37f --- /dev/null +++ b/playwright/plugins/oauth_server/index.ts @@ -0,0 +1,72 @@ +/* +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 http from "http"; +import express from "express"; +import { AddressInfo } from "net"; + +export class OAuthServer { + private server?: http.Server; + + public start(): number { + if (this.server) this.stop(); + + const app = express(); + + // static files. This includes the "authorization endpoint". + app.use(express.static(__dirname + "/res")); + + // token endpoint (see https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint) + app.use("/oauth/token", express.urlencoded({ extended: true })); + app.post("/oauth/token", (req, res) => { + // if the code is valid, accept it. Otherwise, return an error. + const code = req.body.code; + if (code === "valid_auth_code") { + res.send({ + access_token: "oauth_access_token", + token_type: "Bearer", + expires_in: "3600", + }); + } else { + res.send({ error: "bad auth code" }); + } + }); + + // userinfo endpoint (see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) + app.get("/oauth/userinfo", (req, res) => { + // TODO: validate that the request carries an auth header which matches the access token we issued above + + // return an OAuth2 user info object + res.send({ + sub: "alice", + name: "Alice", + }); + }); + + this.server = http.createServer(app); + this.server.listen(); + const address = this.server.address() as AddressInfo; + console.log(`Started OAuth server at ${address.address}:${address.port}`); + return address.port; + } + + public stop(): void { + console.log("Stopping OAuth server"); + const address = this.server.address() as AddressInfo; + this.server.close(); + console.log(`Stopped OAuth server at ${address.address}:${address.port}`); + } +} diff --git a/playwright/plugins/oauth_server/res/oauth/auth.html b/playwright/plugins/oauth_server/res/oauth/auth.html new file mode 100644 index 00000000000..bb01a3e8026 --- /dev/null +++ b/playwright/plugins/oauth_server/res/oauth/auth.html @@ -0,0 +1,42 @@ + + + + + + +

Test OAuth page

+ +
+ + + + +
+ + + + diff --git a/playwright/plugins/postgres/index.ts b/playwright/plugins/postgres/index.ts new file mode 100644 index 00000000000..2b67afefa39 --- /dev/null +++ b/playwright/plugins/postgres/index.ts @@ -0,0 +1,72 @@ +/* +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 { Docker } from "../docker"; + +export const PG_PASSWORD = "p4S5w0rD"; + +/** + * Class to manage a postgres database in docker + */ +export class PostgresDocker extends Docker { + /** + * @param key an opaque string to use when naming the docker containers instantiated by this class + */ + public constructor(private key: string) { + super(); + } + + 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.exec(["pg_isready", "-U", "postgres"], true); + lastErr = null; + break; + } catch (err) { + console.log("pg_isready: failed"); + lastErr = err; + } + } + if (lastErr) { + console.log("rethrowing"); + throw lastErr; + } + } + + public async start(): Promise<{ + ipAddress: string; + containerId: string; + }> { + console.log(new Date(), "starting postgres container"); + const containerId = await this.run({ + image: "postgres", + containerName: `react-sdk-playwright-postgres-${this.key}`, + params: ["--tmpfs=/pgtmpfs", "-e", "PGDATA=/pgtmpfs", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`], + // Optimise for testing - https://www.postgresql.org/docs/current/non-durability.html + cmd: ["-c", `fsync=off`, "-c", `synchronous_commit=off`, "-c", `full_page_writes=off`], + }); + + const ipAddress = await this.getContainerIp(); + console.log(new Date(), "postgres container up"); + + await this.waitForPostgresReady(); + console.log(new Date(), "postgres container ready"); + return { ipAddress, containerId }; + } +} diff --git a/playwright/plugins/sliding-sync-proxy/index.ts b/playwright/plugins/sliding-sync-proxy/index.ts new file mode 100644 index 00000000000..f7e07a8cb15 --- /dev/null +++ b/playwright/plugins/sliding-sync-proxy/index.ts @@ -0,0 +1,69 @@ +/* +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"; +import { PG_PASSWORD, PostgresDocker } from "../postgres"; + +// Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image. +const SLIDING_SYNC_PROXY_TAG = "v0.99.3"; + +export interface ProxyInstance { + containerId: string; + postgresId: string; + port: number; +} + +export class SlidingSyncProxy { + private readonly proxyDocker = new Docker(); + private readonly postgresDocker = new PostgresDocker("sliding-sync"); + private instance: ProxyInstance; + + constructor(private synapseIp: string) {} + + async start(): Promise { + console.log(new Date(), "Starting sliding sync proxy..."); + + const { ipAddress: postgresIp, containerId: postgresId } = await this.postgresDocker.start(); + + 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: [ + "-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/cypress/plugins/utils/port.ts b/playwright/plugins/utils/port.ts similarity index 100% rename from cypress/plugins/utils/port.ts rename to playwright/plugins/utils/port.ts diff --git a/cypress/plugins/utils/homeserver.ts b/playwright/plugins/utils/rand.ts similarity index 69% rename from cypress/plugins/utils/homeserver.ts rename to playwright/plugins/utils/rand.ts index d6a4de04114..5e39f249be3 100644 --- a/cypress/plugins/utils/homeserver.ts +++ b/playwright/plugins/utils/rand.ts @@ -14,15 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -/// +import crypto from "node:crypto"; -export interface HomeserverConfig { - configDir: string; - registrationSecret: string; - baseUrl: string; - port: number; -} - -export interface HomeserverInstance extends HomeserverConfig { - serverId: string; +export function randB64Bytes(numBytes: number): string { + return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); } diff --git a/playwright/plugins/webserver/index.ts b/playwright/plugins/webserver/index.ts new file mode 100644 index 00000000000..1bc2cbfa429 --- /dev/null +++ b/playwright/plugins/webserver/index.ts @@ -0,0 +1,46 @@ +/* +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"; + +export class Webserver { + private server?: http.Server; + + public start(html: string): string { + if (this.server) this.stop(); + + this.server = http.createServer((req, res) => { + res.writeHead(200, { + "Content-Type": "text/html", + }); + res.end(html); + }); + this.server.listen(); + + const address = this.server.address() as AddressInfo; + console.log(`Started webserver at ${address.address}:${address.port}`); + return `http://localhost:${address.port}`; + } + + public stop(): void { + if (!this.server) return; + console.log("Stopping webserver"); + const address = this.server.address() as AddressInfo; + this.server.close(); + console.log(`Stopped webserver at ${address.address}:${address.port}`); + } +} 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 00000000000..53ca7652b4e 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/riot.png b/playwright/sample-files/riot.png similarity index 100% rename from cypress/fixtures/riot.png rename to playwright/sample-files/riot.png 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 00000000000..1a6148c9c14 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 00000000000..7ece0291884 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 00000000000..a62067243e5 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 00000000000..4993fec9b64 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 00000000000..b6c259785e5 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 00000000000..921117c9454 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 00000000000..6968d3176dc 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 00000000000..fb80e1645cc 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 00000000000..3baeecb3d9d 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 00000000000..1d8b5771d41 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 00000000000..ca0fab0f789 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 00000000000..6b903fd7ff2 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 00000000000..6a486d591f0 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 00000000000..22b7b56d274 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 00000000000..56624ac9bd6 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 00000000000..7d48d40fb6f 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 00000000000..62744fc4e93 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 00000000000..c203d12b5d2 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 00000000000..98c1ff245d3 Binary files /dev/null and b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png differ diff --git a/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png new file mode 100644 index 00000000000..9f46fce516b Binary files /dev/null and b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png differ diff --git a/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png b/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png new file mode 100644 index 00000000000..75a9c353dee Binary files /dev/null and b/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png new file mode 100644 index 00000000000..7e8992dca10 Binary files /dev/null and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png new file mode 100644 index 00000000000..dfa6d4f0aa2 Binary files /dev/null and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png new file mode 100644 index 00000000000..c8b8dba45b6 Binary files /dev/null and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png new file mode 100644 index 00000000000..852cb85518c Binary files /dev/null and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-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 00000000000..db91140763a 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 00000000000..a7194257898 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 00000000000..c85c583a19a 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 00000000000..b6990e727ea 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 new file mode 100644 index 00000000000..25380a74b2f Binary files /dev/null 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 new file mode 100644 index 00000000000..3d4ea984a4e Binary files /dev/null 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 new file mode 100644 index 00000000000..ab9fdb2bf62 Binary files /dev/null and b/playwright/snapshots/register/register.spec.ts/registration-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/server-picker-linux.png b/playwright/snapshots/register/register.spec.ts/server-picker-linux.png new file mode 100644 index 00000000000..94b5505b7a9 Binary files /dev/null and b/playwright/snapshots/register/register.spec.ts/server-picker-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 new file mode 100644 index 00000000000..30436d0abc6 Binary files /dev/null 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 new file mode 100644 index 00000000000..8ae5d312e7a Binary files /dev/null and b/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png differ diff --git a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png new file mode 100644 index 00000000000..6439fe305bb Binary files /dev/null and b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png differ diff --git a/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png b/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png new file mode 100644 index 00000000000..e4f6313c97f Binary files /dev/null and b/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png differ diff --git a/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png b/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png new file mode 100644 index 00000000000..b7838267274 Binary files /dev/null and b/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png differ diff --git a/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png b/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png new file mode 100644 index 00000000000..7d8884dc4d0 Binary files /dev/null and b/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png differ diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png new file mode 100644 index 00000000000..f383a828e2c Binary files /dev/null and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png differ diff --git a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png new file mode 100644 index 00000000000..e0fc9cc5bdf Binary files /dev/null and b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png differ diff --git a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png new file mode 100644 index 00000000000..a962c3cbade Binary files /dev/null and b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png b/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png new file mode 100644 index 00000000000..6dced2e9909 Binary files /dev/null and b/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-highlighted-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-highlighted-linux.png new file mode 100644 index 00000000000..c792c4bcf08 Binary files /dev/null and b/playwright/snapshots/room/room-header.spec.ts/room-header-highlighted-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png new file mode 100644 index 00000000000..c5fd90be578 Binary files /dev/null and b/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png new file mode 100644 index 00000000000..0cfb88b5230 Binary files /dev/null and b/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png new file mode 100644 index 00000000000..5d79ae740c7 Binary files /dev/null and b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-highlighted-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-highlighted-linux.png new file mode 100644 index 00000000000..6016bb7e7a1 Binary files /dev/null and b/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-highlighted-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-not-highlighted-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-not-highlighted-linux.png new file mode 100644 index 00000000000..498853a9730 Binary files /dev/null and b/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-not-highlighted-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png new file mode 100644 index 00000000000..b7fea971922 Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png new file mode 100644 index 00000000000..12996f4e5b8 Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png new file mode 100644 index 00000000000..f5231463488 Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png new file mode 100644 index 00000000000..6b1e058c6a1 Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png new file mode 100644 index 00000000000..502e60cb1f8 Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/window-12px-linux.png new file mode 100644 index 00000000000..3247abd7c1b Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png new file mode 100644 index 00000000000..57e2a4026cc Binary files /dev/null and b/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png new file mode 100644 index 00000000000..af35cc8bb4a Binary files /dev/null and b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png differ diff --git a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png new file mode 100644 index 00000000000..d59d2946da7 Binary files /dev/null and b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png differ diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png new file mode 100644 index 00000000000..e5d1ddef4f8 Binary files /dev/null and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/playwright/snapshots/settings/security-user-settings-tab.spec.ts/Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/security-user-settings-tab.spec.ts/Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1-linux.png new file mode 100644 index 00000000000..c59d60178d5 Binary files /dev/null and b/playwright/snapshots/settings/security-user-settings-tab.spec.ts/Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png new file mode 100644 index 00000000000..ef7536d455d Binary files /dev/null and b/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/space-create-menu-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/space-create-menu-linux.png new file mode 100644 index 00000000000..6d2e83b23db Binary files /dev/null and b/playwright/snapshots/spaces/spaces.spec.ts/space-create-menu-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 new file mode 100644 index 00000000000..66b8af0e5be Binary files /dev/null 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 new file mode 100644 index 00000000000..6f55f2fd008 Binary files /dev/null and b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png new file mode 100644 index 00000000000..dcac67dc1f0 Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png new file mode 100644 index 00000000000..37405cd821a Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png new file mode 100644 index 00000000000..26f5bfdfa98 Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-no-indicator-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-no-indicator-linux.png new file mode 100644 index 00000000000..c7a1f9fea15 Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-no-indicator-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png new file mode 100644 index 00000000000..ec5a8193d25 Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png new file mode 100644 index 00000000000..f0f6cee3e6a Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-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 00000000000..281f1cebe5b 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 00000000000..5703f384498 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 00000000000..0a3d265a601 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 00000000000..0453bf932a9 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 00000000000..077ecbf7176 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 00000000000..c0a01c99fb0 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 00000000000..87e65a86ae5 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 00000000000..98ec9e0cf66 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 00000000000..445d616ea40 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 00000000000..194ecd07fbc 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 00000000000..2b990bb3b86 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 00000000000..294cd3ec7ab 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 00000000000..f0064c81e19 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 00000000000..001ac64f2a6 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 00000000000..f7a276d2f72 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 00000000000..de056e0fa56 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 00000000000..077ecbf7176 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 00000000000..e1cd5ab19c3 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 00000000000..4fb29024560 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 00000000000..ceddab3312b 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 00000000000..5fba124a929 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 00000000000..ff75b3473fe 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 00000000000..100dc86c7a5 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/image-in-timeline-default-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png new file mode 100644 index 00000000000..dfc55550aae Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-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 00000000000..800ceefc6e4 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 00000000000..9d2fcdf272d 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 00000000000..f85715b0765 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 00000000000..64d44a9778b 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 00000000000..4c553cfdafb 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 00000000000..49f2c0bad83 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/url-preview-linux.png differ diff --git a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-app-download-dialog-1-linux.png b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-app-download-dialog-1-linux.png new file mode 100644 index 00000000000..0c7fc94a0ed Binary files /dev/null and b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-app-download-dialog-1-linux.png differ diff --git a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png new file mode 100644 index 00000000000..ea1fa63bdec Binary files /dev/null and b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png differ diff --git a/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png new file mode 100644 index 00000000000..75b64546d63 Binary files /dev/null and b/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png differ diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png new file mode 100644 index 00000000000..61ab6601577 Binary files /dev/null and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-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 00000000000..20618f5d66e 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 new file mode 100644 index 00000000000..55f979f3ce5 --- /dev/null +++ b/playwright/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2016", + "jsx": "react", + "lib": ["ESNext", "es2021", "dom", "dom.iterable"], + "resolveJsonModule": true, + "esModuleInterop": true, + "moduleResolution": "node", + "module": "es2022" + }, + "include": [ + "**/*.ts", + "../src/@types/matrix-js-sdk.d.ts", + "../node_modules/matrix-js-sdk/src/@types/*.d.ts", + "../node_modules/matrix-js-sdk/node_modules/@matrix-org/olm/index.d.ts" + ] +} diff --git a/post-release.sh b/post-release.sh deleted file mode 100755 index 916d4b6f181..00000000000 --- a/post-release.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -./node_modules/matrix-js-sdk/post-release.sh "$@" diff --git a/release.sh b/release.sh deleted file mode 100755 index a9fb09e1fa6..00000000000 --- a/release.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# -# Script to perform a release of matrix-react-sdk. - -set -e - -cd "$(dirname "$0")" - -# This link seems to get eaten by the release process, so ensure it exists. -yarn link matrix-js-sdk - -./node_modules/matrix-js-sdk/release.sh "$@" diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 6a3d9f03d41..d120194491b 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -2,7 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2017 - 2019 New Vector Ltd -Copyright 2019 - 2021 The Matrix.org Foundation C.I.C +Copyright 2019 - 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. @@ -17,8 +17,9 @@ See the License for the specific language governing permissions and limitations under the License. */ +@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); +@import url("@vector-im/compound-web/dist/style.css"); @import "./_font-sizes.pcss"; -@import "./_font-weights.pcss"; @import "./_animations.pcss"; @import "./_spacing.pcss"; @import url("maplibre-gl/dist/maplibre-gl.css"); @@ -51,12 +52,30 @@ limitations under the License. --dialog-zIndex-standard: calc(var(--dialog-zIndex-standard-background) + 1); /* 4012 */ } -@media only percy { - :root { - --percy-color-avatar: $username-variant2-color; - --percy-color-displayName: $username-variant1-color; - --percy-color-replyChain-border: $username-variant1-color; - } +#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 + */ +[class^="cpd-theme"][class^="cpd-theme"] { + /** + * The design tokens package currently does not expose the fallback fonts + * We want to keep on re-using `$font-family` to not break custom themes + * and because we can to use `Twemoji` to display emoji rather than using + * system ones + */ + --cpd-font-family-sans: $font-family; } @media (prefers-reduced-motion) { @@ -76,8 +95,20 @@ html { } body { - font-family: $font-family; - font-size: $font-15px; + font: var(--cpd-font-body-md-regular); + letter-spacing: var(--cpd-font-letter-spacing-body-md); + /** + * We want to apply Inter Dynamic metrics (https://rsms.me/inter/dynmetrics/) + * We need to tweak the `letter-spacing` property and doing so, disables by + * default the optional ligatures + * `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; + background-color: $background; color: $primary-content; border: 0px; @@ -110,6 +141,22 @@ code { color: $muted-fg-color; } +.text-primary { + color: $primary-content; +} + +.text-secondary { + color: $secondary-content; +} + +.mx_Verified { + color: $e2e-verified-color; +} + +.mx_Untrusted { + color: $e2e-warning-color; +} + b { /* On Firefox, the default weight for `` is `bolder` which results in no bold */ /* effect since we only have specific weights of our fonts available. */ @@ -118,8 +165,8 @@ b { h2 { color: $primary-content; - font-weight: 400; - font-size: $font-18px; + font: var(--cpd-font-heading-lg-regular); + letter-spacing: var(--cpd-font-letter-spacing-heading-lg); margin-top: 16px; margin-bottom: 16px; } @@ -133,10 +180,9 @@ a:visited { input[type="text"], input[type="search"], input[type="password"] { - font-family: inherit; padding: 9px; - font-size: $font-14px; - font-weight: var(--font-semi-bold); + font: var(--cpd-font-body-md-semibold); + font-weight: var(--cpd-font-weight-semibold); min-width: 0; } @@ -191,7 +237,7 @@ textarea:focus { /* accessible (focusable) components. Not intended for buttons, but */ /* should be used on things like focusable containers where the outline */ /* is usually not helping anyone. */ -*:focus:not(.focus-visible) { +*:focus:not(:focus-visible) { outline: none; } @@ -236,7 +282,7 @@ legend { background-color: transparent; color: $input-darker-fg-color; border-radius: 4px; - border: 1px solid rgba($primary-content, 0.1); + border: 1px solid $secondary-hairline-color; /* these things should probably not be defined globally */ margin: 9px; } @@ -249,30 +295,7 @@ legend { :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type="text"]::placeholder, :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type="search"]::placeholder, .mx_textinput input::placeholder { - color: rgba($input-darker-fg-color, 0.75); - } -} - -/*** panels ***/ -.dark-panel { - background-color: $dark-panel-bg-color; - - :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type="text"], - :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type="search"], - .mx_textinput { - color: $input-darker-fg-color; - background-color: $background; - border: none; - } -} - -.light-panel { - :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type="text"], - :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type="search"], - .mx_textinput { - color: $input-darker-fg-color; - background-color: $input-lighter-bg-color; - border: none; + color: $input-placeholder; } } @@ -306,16 +329,30 @@ legend { justify-content: center; } +.mx_Dialog_border { + z-index: var(--dialog-zIndex-standard); + position: relative; + width: 100%; + max-width: fit-content; + box-sizing: border-box; + max-height: calc(100% - var(--cpd-space-6x)); + display: flex; + flex-direction: column; + + .mx_Dialog_lightbox & { + /* The lightbox isn't so much of a dialog as a fullscreen overlay. We + don't want the glass border. */ + display: contents; + } +} + .mx_Dialog { background-color: $background; color: $light-fg-color; - z-index: var(--dialog-zIndex-standard); font-size: $font-15px; position: relative; - padding: 24px; - max-height: 80%; - box-shadow: 2px 15px 30px 0 $dialog-shadow-color; - border-radius: 8px; + padding: var(--cpd-space-8x) var(--cpd-space-10x); + box-sizing: border-box; overflow-y: auto; .mx_Dialog_staticWrapper & { @@ -336,11 +373,12 @@ legend { /* Styles copied/inspired by GroupLayout, ReplyTile, and EventTile variants. */ .markdown-body { + font: var(--cpd-font-body-md-regular) !important; + letter-spacing: var(--cpd-font-letter-spacing-body-md); font-family: inherit !important; white-space: normal !important; line-height: inherit !important; color: inherit; /* inherit the colour from the dark or light theme by default (but not for code blocks) */ - font-size: $font-14px; pre, code { @@ -396,6 +434,7 @@ legend { blockquote { border-left: 2px solid $blockquote-bar-color; + color: $secondary-content; border-radius: 2px; padding: 0 10px; } @@ -414,7 +453,6 @@ legend { width: 100%; height: 100%; background-color: $dialog-backdrop-color; - opacity: 0.8; z-index: var(--dialog-zIndex-standard-background); &.mx_Dialog_staticBackground { @@ -449,6 +487,7 @@ legend { display: inline-block; width: 100%; box-sizing: border-box; + letter-spacing: var(--cpd-font-letter-spacing-heading-lg); &.danger { color: $alert; @@ -457,47 +496,50 @@ legend { .mx_Dialog_header { position: relative; - padding: 3px 0; - margin-bottom: 10px; + padding: 0; + padding-inline-end: 20px; /* Reserve room for the close button */ + margin-bottom: var(--cpd-space-2x); &.mx_Dialog_headerWithButton > .mx_Dialog_title { text-align: center; } - - &.mx_Dialog_headerWithCancel { - padding-right: 20px; /* leave space for the 'X' cancel button */ - } - - &.mx_Dialog_headerWithCancelOnly { - padding: 0 20px 0 0; - margin: 0; - } } @define-mixin customisedCancelButton { - mask: url("$(res)/img/cancel.svg"); - mask-repeat: no-repeat; - mask-position: center; - mask-size: cover; - background-color: $dialog-close-fg-color; cursor: pointer; - position: unset; - width: unset; - height: unset; + position: relative; + width: 28px; + height: 28px; + border-radius: 14px; + background-color: var(--cpd-color-bg-subtle-secondary); + + &:hover { + background-color: var(--cpd-color-bg-subtle-primary); + } + + &::before { + content: ""; + width: 28px; + height: 28px; + position: absolute; + mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 20px; + background-color: var(--cpd-color-icon-secondary); + } } .mx_Dialog_cancelButton { @mixin customisedCancelButton; - width: 18px; - height: 18px; position: absolute; - top: 10px; - right: 0; + top: var(--cpd-space-4x); + right: var(--cpd-space-4x); } .mx_Dialog_content { margin: 24px 0 68px; - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); color: $primary-content; word-wrap: break-word; } @@ -533,11 +575,10 @@ legend { /* align images in buttons (eg spinners) */ vertical-align: middle; border: 0px; - border-radius: 8px; - font-family: $font-family; - font-size: $font-14px; + border-radius: 24px; + font: var(--cpd-font-body-md-regular); color: $button-fg-color; - background-color: $accent; + background-color: var(--cpd-color-bg-action-primary-rest); width: auto; padding: 7px; padding-left: 1.5em; @@ -545,7 +586,7 @@ legend { cursor: pointer; display: inline-block; - &:not(.focus-visible) { + &:not(:focus-visible) { outline: none; } } @@ -559,7 +600,10 @@ legend { * Elements that should not be styled like a dialog button are mentioned in a :not() pseudo-class. * For the widest browser support, we use multiple :not pseudo-classes instead of :not(.a, .b). */ -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton), +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ), .mx_Dialog input[type="submit"], .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons input[type="submit"] { @@ -569,18 +613,24 @@ legend { margin-bottom: 5px; /* flip colours for the secondary ones */ - font-weight: var(--font-semi-bold); - border: 1px solid $accent; - color: $accent; - background-color: $button-secondary-bg-color; + font-weight: var(--cpd-font-weight-semibold); + border: 1px solid var(--cpd-color-border-interactive-secondary); + color: var(--cpd-color-text-primary); + background-color: transparent; font-family: inherit; } -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):last-child { +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ):last-child { margin-right: 0px; } -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):focus, +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons input[type="submit"]:focus { @@ -589,29 +639,37 @@ legend { .mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), .mx_Dialog input[type="submit"].mx_Dialog_primary, -.mx_Dialog_buttons button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), +.mx_Dialog_buttons + button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { - color: $accent-fg-color; - background-color: $accent; + color: var(--cpd-color-text-on-solid-primary); + background-color: var(--cpd-color-bg-action-primary-rest); + border-color: var(--cpd-color-bg-action-primary-rest); min-width: 156px; } .mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), .mx_Dialog input[type="submit"].danger, -.mx_Dialog_buttons button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), +.mx_Dialog_buttons + button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button), .mx_Dialog_buttons input[type="submit"].danger { - background-color: $alert; - border: solid 1px $alert; - color: $accent-fg-color; + background-color: var(--cpd-color-bg-critical-primary); + border: solid 1px var(--cpd-color-bg-critical-primary); + color: var(--cpd-color-text-on-solid-primary); } .mx_Dialog button.warning:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), .mx_Dialog input[type="submit"].warning { - border: solid 1px $alert; - color: $alert; + border: solid 1px var(--cpd-color-border-critical-subtle); + color: var(--cpd-color-text-critical-primary); } -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):disabled, +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ):disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons input[type="submit"]:disabled { @@ -621,15 +679,23 @@ legend { } /* Spinner Dialog overide */ -.mx_Dialog_wrapper.mx_Dialog_spinner .mx_Dialog { - width: auto; - border-radius: 8px; - padding: 8px; - box-shadow: none; +.mx_Dialog_wrapper.mx_Dialog_spinner { + /* This is not a real dialog, so we shouldn't show a glass border */ + .mx_Dialog_border { + display: contents; + } - /* Don't show scroll-bars on spinner dialogs */ - overflow-x: hidden; - overflow-y: hidden; + .mx_Dialog { + inline-size: auto; + block-size: auto; + border-radius: 8px; + padding: 8px; + box-shadow: none; + + /* Don't show scroll-bars on spinner dialogs */ + overflow-x: hidden; + overflow-y: hidden; + } } /* TODO: Review mx_GeneralButton usage to see if it can use a different class */ @@ -686,12 +752,13 @@ legend { color: $username-variant6-color; } -.mx_Username_color7 { - color: $username-variant7-color; -} - -.mx_Username_color8 { - color: $username-variant8-color; +.mx_AppWarning, +.mx_AppPermission { + text-align: center; + display: flex; + height: 100%; + flex-direction: column; + align-items: center; } @define-mixin ProgressBarColour $colour { @@ -748,7 +815,7 @@ legend { @define-mixin LegacyCallButton { box-sizing: border-box; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); height: $font-24px; line-height: $font-24px; margin-right: 0; @@ -770,7 +837,7 @@ legend { @define-mixin ThreadRepliesAmount { color: $secondary-content; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); white-space: nowrap; position: relative; padding: 0 $spacing-12 0 $spacing-8; @@ -785,18 +852,17 @@ legend { mask-size: contain; height: 18px; min-width: 18px; - background-color: $secondary-content !important; + background-color: $icon-button-color !important; } @define-mixin composerButtonHighLight { - background: rgba($accent, 0.25); - /* make the icon the accent color too */ + background: var(--cpd-color-bg-subtle-primary); &::before { - background-color: $accent !important; + background-color: var(--cpd-color-icon-primary) !important; } } -@define-mixin composerButton $border-radius, $hover-color { +@define-mixin composerButton $border-radius, $hover-color, $hover-bg { --size: 26px; position: relative; cursor: pointer; @@ -817,6 +883,7 @@ legend { mask-repeat: no-repeat; mask-size: contain; mask-position: center; + z-index: 2; } &::after { @@ -832,7 +899,7 @@ legend { &:hover { &::after { - background: rgba($hover-color, 0.1); + background: $hover-bg; } &::before { @@ -840,3 +907,10 @@ legend { } } } + +.mx_lineClamp { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--mx-line-clamp, 1); + overflow: hidden; +} diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 56628095f2f..a7c79bfbf2e 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -2,7 +2,6 @@ @import "./_animations.pcss"; @import "./_common.pcss"; @import "./_font-sizes.pcss"; -@import "./_font-weights.pcss"; @import "./_spacing.pcss"; @import "./components/views/beacon/_BeaconListItem.pcss"; @import "./components/views/beacon/_BeaconStatus.pcss"; @@ -21,6 +20,7 @@ @import "./components/views/dialogs/polls/_PollListItem.pcss"; @import "./components/views/dialogs/polls/_PollListItemEnded.pcss"; @import "./components/views/elements/_AppPermission.pcss"; +@import "./components/views/elements/_AppWarning.pcss"; @import "./components/views/elements/_FilterDropdown.pcss"; @import "./components/views/elements/_FilterTabGroup.pcss"; @import "./components/views/elements/_LearnMore.pcss"; @@ -52,6 +52,8 @@ @import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./components/views/typography/_Caption.pcss"; +@import "./components/views/utils/_Box.pcss"; +@import "./components/views/utils/_Flex.pcss"; @import "./compound/_Icon.pcss"; @import "./compound/_SuccessDialog.pcss"; @import "./structures/_AutoHideScrollbar.pcss"; @@ -83,13 +85,17 @@ @import "./structures/_SpaceRoomView.pcss"; @import "./structures/_SplashPage.pcss"; @import "./structures/_TabbedView.pcss"; +@import "./structures/_ThreadsActivityCentre.pcss"; @import "./structures/_ToastContainer.pcss"; @import "./structures/_UploadBar.pcss"; @import "./structures/_UserMenu.pcss"; @import "./structures/_ViewSource.pcss"; @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"; @import "./views/audio_messages/_AudioPlayer.pcss"; @import "./views/audio_messages/_PlayPauseButton.pcss"; @@ -140,6 +146,7 @@ @import "./views/dialogs/_JoinRuleDropdown.pcss"; @import "./views/dialogs/_LeaveSpaceDialog.pcss"; @import "./views/dialogs/_LocationViewDialog.pcss"; +@import "./views/dialogs/_LogoutDialog.pcss"; @import "./views/dialogs/_ManageRestrictedJoinRuleDialog.pcss"; @import "./views/dialogs/_MessageEditHistoryDialog.pcss"; @import "./views/dialogs/_ModalWidgetDialog.pcss"; @@ -188,6 +195,7 @@ @import "./views/elements/_InteractiveTooltip.pcss"; @import "./views/elements/_InviteReason.pcss"; @import "./views/elements/_LabelledCheckbox.pcss"; +@import "./views/elements/_LanguageDropdown.pcss"; @import "./views/elements/_MiniAvatarUploader.pcss"; @import "./views/elements/_Pill.pcss"; @import "./views/elements/_PowerSelector.pcss"; @@ -201,7 +209,6 @@ @import "./views/elements/_SearchWarning.pcss"; @import "./views/elements/_ServerPicker.pcss"; @import "./views/elements/_SettingsFlag.pcss"; -@import "./views/elements/_Slider.pcss"; @import "./views/elements/_Spinner.pcss"; @import "./views/elements/_StyledCheckbox.pcss"; @import "./views/elements/_StyledRadioButton.pcss"; @@ -210,7 +217,6 @@ @import "./views/elements/_TextWithTooltip.pcss"; @import "./views/elements/_ToggleSwitch.pcss"; @import "./views/elements/_Tooltip.pcss"; -@import "./views/elements/_TooltipButton.pcss"; @import "./views/elements/_UseCaseSelection.pcss"; @import "./views/elements/_UseCaseSelectionButton.pcss"; @import "./views/elements/_Validation.pcss"; @@ -246,6 +252,7 @@ @import "./views/messages/_RedactedBody.pcss"; @import "./views/messages/_RoomAvatarEvent.pcss"; @import "./views/messages/_TextualEvent.pcss"; +@import "./views/messages/_TimelineSeparator.pcss"; @import "./views/messages/_UnknownBody.pcss"; @import "./views/messages/_ViewSourceEvent.pcss"; @import "./views/messages/_common_CryptoEvent.pcss"; @@ -265,6 +272,7 @@ @import "./views/rooms/_Autocomplete.pcss"; @import "./views/rooms/_AuxPanel.pcss"; @import "./views/rooms/_BasicMessageComposer.pcss"; +@import "./views/rooms/_CallGuestLinkButton.pcss"; @import "./views/rooms/_DecryptionFailureBar.pcss"; @import "./views/rooms/_E2EIcon.pcss"; @import "./views/rooms/_EditMessageComposer.pcss"; @@ -275,10 +283,10 @@ @import "./views/rooms/_HistoryTile.pcss"; @import "./views/rooms/_IRCLayout.pcss"; @import "./views/rooms/_JumpToBottomButton.pcss"; +@import "./views/rooms/_LegacyRoomHeader.pcss"; @import "./views/rooms/_LinkPreviewGroup.pcss"; @import "./views/rooms/_LinkPreviewWidget.pcss"; @import "./views/rooms/_LiveContentSummary.pcss"; -@import "./views/rooms/_MemberInfo.pcss"; @import "./views/rooms/_MemberList.pcss"; @import "./views/rooms/_MessageComposer.pcss"; @import "./views/rooms/_MessageComposerFormatBar.pcss"; @@ -287,13 +295,13 @@ @import "./views/rooms/_PinnedEventTile.pcss"; @import "./views/rooms/_PresenceLabel.pcss"; @import "./views/rooms/_ReadReceiptGroup.pcss"; -@import "./views/rooms/_RecentlyViewedButton.pcss"; @import "./views/rooms/_ReplyPreview.pcss"; @import "./views/rooms/_ReplyTile.pcss"; @import "./views/rooms/_RoomBreadcrumbs.pcss"; @import "./views/rooms/_RoomCallBanner.pcss"; @import "./views/rooms/_RoomHeader.pcss"; @import "./views/rooms/_RoomInfoLine.pcss"; +@import "./views/rooms/_RoomKnocksBar.pcss"; @import "./views/rooms/_RoomList.pcss"; @import "./views/rooms/_RoomListHeader.pcss"; @import "./views/rooms/_RoomPreviewBar.pcss"; @@ -303,7 +311,9 @@ @import "./views/rooms/_RoomUpgradeWarningBar.pcss"; @import "./views/rooms/_SearchBar.pcss"; @import "./views/rooms/_SendMessageComposer.pcss"; +@import "./views/rooms/_SpaceScopeHeader.pcss"; @import "./views/rooms/_Stickers.pcss"; +@import "./views/rooms/_ThirdPartyMemberInfo.pcss"; @import "./views/rooms/_ThreadSummary.pcss"; @import "./views/rooms/_TopUnreadMessagesBar.pcss"; @import "./views/rooms/_VoiceRecordComposerTile.pcss"; @@ -322,9 +332,12 @@ @import "./views/settings/_JoinRuleSettings.pcss"; @import "./views/settings/_KeyboardShortcut.pcss"; @import "./views/settings/_LayoutSwitcher.pcss"; +@import "./views/settings/_NotificationPusherSettings.pcss"; +@import "./views/settings/_NotificationSettings2.pcss"; @import "./views/settings/_Notifications.pcss"; @import "./views/settings/_PhoneNumbers.pcss"; -@import "./views/settings/_ProfileSettings.pcss"; +@import "./views/settings/_PowerLevelSelector.pcss"; +@import "./views/settings/_RoomProfileSettings.pcss"; @import "./views/settings/_SecureBackupPanel.pcss"; @import "./views/settings/_SetIdServer.pcss"; @import "./views/settings/_SetIntegrationManager.pcss"; @@ -332,9 +345,13 @@ @import "./views/settings/_SpellCheckLanguages.pcss"; @import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_UpdateCheckButton.pcss"; +@import "./views/settings/_UserProfileSettings.pcss"; +@import "./views/settings/tabs/_SettingsBanner.pcss"; +@import "./views/settings/tabs/_SettingsIndent.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss"; @import "./views/settings/tabs/_SettingsTab.pcss"; @import "./views/settings/tabs/room/_NotificationSettingsTab.pcss"; +@import "./views/settings/tabs/room/_PeopleRoomSettingsTab.pcss"; @import "./views/settings/tabs/room/_RolesRoomSettingsTab.pcss"; @import "./views/settings/tabs/room/_SecurityRoomSettingsTab.pcss"; @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss"; @@ -373,7 +390,6 @@ @import "./views/voip/_LegacyCallViewSidebar.pcss"; @import "./views/voip/_VideoFeed.pcss"; @import "./voice-broadcast/atoms/_LiveBadge.pcss"; -@import "./voice-broadcast/atoms/_PlaybackControlButton.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss"; diff --git a/res/css/_font-sizes.pcss b/res/css/_font-sizes.pcss index 5d83ff83df6..bb447ebfa0b 100644 --- a/res/css/_font-sizes.pcss +++ b/res/css/_font-sizes.pcss @@ -21,63 +21,33 @@ limitations under the License. * "Font size" setting). They exist to make the job of converting designs (which tend to be based in pixels) into CSS * easier. * - * That means that, slightly confusingly, `$font-10px` is only *actually* 10px at the default font size: at a base - * `font-size` of 15, it is actually 15px. */ -$font-1px: 0.1rem; -$font-1-5px: 0.15rem; -$font-2px: 0.2rem; -$font-3px: 0.3rem; -$font-4px: 0.4rem; -$font-5px: 0.5rem; -$font-6px: 0.6rem; -$font-7px: 0.7rem; -$font-8px: 0.8rem; -$font-9px: 0.9rem; -$font-10px: 1rem; -$font-10-4px: 1.04rem; -$font-11px: 1.1rem; -$font-12px: 1.2rem; -$font-13px: 1.3rem; -$font-14px: 1.4rem; -$font-15px: 1.5rem; -$font-16px: 1.6rem; -$font-17px: 1.7rem; -$font-18px: 1.8rem; -$font-19px: 1.9rem; -$font-20px: 2rem; -$font-21px: 2.1rem; -$font-22px: 2.2rem; -$font-23px: 2.3rem; -$font-24px: 2.4rem; -$font-25px: 2.5rem; -$font-26px: 2.6rem; -$font-27px: 2.7rem; -$font-28px: 2.8rem; -$font-29px: 2.9rem; -$font-30px: 3rem; -$font-31px: 3.1rem; -$font-32px: 3.2rem; -$font-33px: 3.3rem; -$font-34px: 3.4rem; -$font-35px: 3.5rem; -$font-36px: 3.6rem; -$font-37px: 3.7rem; -$font-38px: 3.8rem; -$font-39px: 3.9rem; -$font-40px: 4rem; -$font-41px: 4.1rem; -$font-42px: 4.2rem; -$font-43px: 4.3rem; -$font-44px: 4.4rem; -$font-45px: 4.5rem; -$font-46px: 4.6rem; -$font-47px: 4.7rem; -$font-48px: 4.8rem; -$font-49px: 4.9rem; -$font-50px: 5rem; -$font-51px: 5.1rem; -$font-52px: 5.2rem; -$font-78px: 7.8rem; -$font-88px: 8.8rem; -$font-400px: 40rem; +$font-1px: 0.0625rem; +$font-8px: 0.5rem; +$font-9px: 0.5625rem; +$font-10px: 0.625rem; +$font-10-4px: 0.6275rem; +$font-11px: 0.6875rem; +$font-12px: 0.75rem; +$font-13px: 0.8125rem; +$font-14px: 0.875rem; +$font-15px: 0.9375rem; +$font-16px: 1rem; +$font-17px: 1.0625rem; +$font-18px: 1.125rem; +$font-20px: 1.25rem; +$font-22px: 1.375rem; +$font-23px: 1.4375rem; +$font-24px: 1.5rem; +$font-25px: 1.5625rem; +$font-26px: 1.625rem; +$font-28px: 1.75rem; +$font-29px: 1.8125rem; +$font-30px: 1.875rem; +$font-32px: 2rem; +$font-34px: 2.125rem; +$font-35px: 2.1875rem; +$font-39px: 2.4375rem; +$font-42px: 2.625rem; +$font-44px: 2.75rem; +$font-48px: 3rem; diff --git a/res/css/components/views/beacon/_BeaconListItem.pcss b/res/css/components/views/beacon/_BeaconListItem.pcss index c9b39bbebf4..3389ccc3a2f 100644 --- a/res/css/components/views/beacon/_BeaconListItem.pcss +++ b/res/css/components/views/beacon/_BeaconListItem.pcss @@ -55,7 +55,7 @@ limitations under the License. margin-bottom: $spacing-8; .mx_BeaconStatus_label { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); } } diff --git a/res/css/components/views/beacon/_DialogSidebar.pcss b/res/css/components/views/beacon/_DialogSidebar.pcss index c33f74e0366..31d3b7b16d5 100644 --- a/res/css/components/views/beacon/_DialogSidebar.pcss +++ b/res/css/components/views/beacon/_DialogSidebar.pcss @@ -57,6 +57,6 @@ limitations under the License. } .mx_DialogSidebar_noResults { - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); color: $secondary-content; } diff --git a/res/css/components/views/beacon/_OwnBeaconStatus.pcss b/res/css/components/views/beacon/_OwnBeaconStatus.pcss index dedf02da7a6..a0776b942aa 100644 --- a/res/css/components/views/beacon/_OwnBeaconStatus.pcss +++ b/res/css/components/views/beacon/_OwnBeaconStatus.pcss @@ -27,5 +27,5 @@ limitations under the License. .mx_OwnBeaconStatus_destructiveButton { /* override button link_inline styles */ color: $alert !important; - font-weight: var(--font-semi-bold) !important; + font-weight: var(--cpd-font-weight-semibold) !important; } diff --git a/res/css/components/views/context_menus/_KebabContextMenu.pcss b/res/css/components/views/context_menus/_KebabContextMenu.pcss index 1594420aea7..5ab6782653e 100644 --- a/res/css/components/views/context_menus/_KebabContextMenu.pcss +++ b/res/css/components/views/context_menus/_KebabContextMenu.pcss @@ -16,5 +16,5 @@ limitations under the License. .mx_KebabContextMenu_icon { width: 24px; - color: $secondary-content; + color: $icon-button-color; } diff --git a/res/css/components/views/dialogs/polls/_PollDetailHeader.pcss b/res/css/components/views/dialogs/polls/_PollDetailHeader.pcss index 6f29b6e08fd..af3a9c2a700 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 16ea5dcce07..51b2a07d9b1 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/_AppPermission.pcss b/res/css/components/views/elements/_AppPermission.pcss index be78efa43b4..3b770c78798 100644 --- a/res/css/components/views/elements/_AppPermission.pcss +++ b/res/css/components/views/elements/_AppPermission.pcss @@ -16,62 +16,31 @@ limitations under the License. */ .mx_AppPermission { - > div { - margin-bottom: 12px; - } - - h4 { - margin: 0; - padding: 0; - } - - .mx_AppPermission_smallText { - font-size: $font-12px; - } - + font-size: $font-12px; + width: 100%; /* make mx_AppPermission fill width of mx_AppTileBody so that scroll bar appears on the edge */ + overflow-y: scroll; .mx_AppPermission_bolder { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); } + .mx_AppPermission_content { + margin-block: auto; /* place at the center */ - .mx_AppPermission_helpIcon { - margin-top: 1px; - margin-right: 2px; - width: 10px; - height: 10px; - display: inline-block; + > div { + margin-block: 12px; + } - &::before { - display: inline-block; - background-color: $accent; - mask-repeat: no-repeat; - mask-size: 12px; - width: 12px; - height: 12px; - mask-position: center; - content: ""; - vertical-align: middle; - mask-image: url("$(res)/img/feather-customised/help-circle.svg"); + .mx_AppPermission_content_bolder { + font-weight: var(--font-semi-bold); } - } -} -.mx_Tooltip.mx_Tooltip--appPermission { - box-shadow: none; - background-color: $tooltip-timeline-bg-color; - color: $tooltip-timeline-fg-color; - border: none; - border-radius: 3px; - padding: 6px 8px; + .mx_TextWithTooltip_target--helpIcon { + display: inline-block; + height: $font-14px; /* align with characters on the same line */ + vertical-align: middle; - &.mx_Tooltip--appPermission--dark { - .mx_Tooltip_chevron::after { - border-right-color: $tooltip-timeline-bg-color; + .mx_Icon { + color: $accent; + } } } - - ul { - list-style-position: inside; - padding-left: 2px; - margin-left: 0; - } } diff --git a/res/css/components/views/elements/_AppWarning.pcss b/res/css/components/views/elements/_AppWarning.pcss new file mode 100644 index 00000000000..8d859d12a86 --- /dev/null +++ b/res/css/components/views/elements/_AppWarning.pcss @@ -0,0 +1,25 @@ +/* +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. +*/ + +.mx_AppWarning { + font-size: $font-16px; + justify-content: center; + + h4 { + margin: 0; + padding: 0; + } +} diff --git a/res/css/components/views/elements/_FilterDropdown.pcss b/res/css/components/views/elements/_FilterDropdown.pcss index a73a45c03ee..dc264ca9226 100644 --- a/res/css/components/views/elements/_FilterDropdown.pcss +++ b/res/css/components/views/elements/_FilterDropdown.pcss @@ -72,7 +72,7 @@ limitations under the License. } .mx_FilterDropdown_optionLabel { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); display: block; } diff --git a/res/css/components/views/elements/_FilterTabGroup.pcss b/res/css/components/views/elements/_FilterTabGroup.pcss index 05329cb7d00..5f7338f81e3 100644 --- a/res/css/components/views/elements/_FilterTabGroup.pcss +++ b/res/css/components/views/elements/_FilterTabGroup.pcss @@ -38,8 +38,8 @@ limitations under the License. &:checked + span { color: $accent; - font-weight: var(--font-semi-bold); - // underline + font-weight: var(--cpd-font-weight-semibold); + /* 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 cecc0e1365a..bc0419a4937 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; } } @@ -42,7 +55,7 @@ limitations under the License. padding: $spacing-12; display: flex; font-size: $font-12px; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); background: linear-gradient(rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0)); } diff --git a/res/css/components/views/polls/_PollOption.pcss b/res/css/components/views/polls/_PollOption.pcss index da4c66d6cf1..81301f56464 100644 --- a/res/css/components/views/polls/_PollOption.pcss +++ b/res/css/components/views/polls/_PollOption.pcss @@ -52,27 +52,26 @@ limitations under the License. .mx_PollOption_winnerIcon { height: 12px; width: 12px; - color: $accent; + color: var(--cpd-color-icon-accent-tertiary); margin-right: $spacing-4; vertical-align: middle; } .mx_PollOption_checked { - border-color: $accent; + border-color: var(--cpd-color-border-interactive-hovered); .mx_PollOption_popularityBackground { .mx_PollOption_popularityAmount { - background-color: $accent; + background-color: var(--cpd-color-icon-accent-tertiary); } } - // 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 { + input[type="radio"]:checked + div { border-width: 2px; - border-color: $accent; - background-color: $accent; + border-color: var(--cpd-color-icon-accent-tertiary); + background-color: var(--cpd-color-icon-accent-tertiary); background-image: url("$(res)/img/element-icons/check-white.svg"); background-size: 12px; background-repeat: no-repeat; diff --git a/res/css/components/views/settings/devices/_CurrentDeviceSection.pcss b/res/css/components/views/settings/devices/_CurrentDeviceSection.pcss index 552270db1d0..d91dd64575c 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 b62cc531893..841102536fe 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/_DeviceDetails.pcss b/res/css/components/views/settings/devices/_DeviceDetails.pcss index 9b34fa378aa..00074f0d2fd 100644 --- a/res/css/components/views/settings/devices/_DeviceDetails.pcss +++ b/res/css/components/views/settings/devices/_DeviceDetails.pcss @@ -55,7 +55,7 @@ limitations under the License. } .mx_DeviceDetails_metadataTable { - font-size: $font-12px; + font: var(--cpd-font-body-sm-regular); color: $secondary-content; width: 100%; diff --git a/res/css/components/views/settings/devices/_DeviceExpandDetailsButton.pcss b/res/css/components/views/settings/devices/_DeviceExpandDetailsButton.pcss index b8972d62272..64fb7cfc24e 100644 --- a/res/css/components/views/settings/devices/_DeviceExpandDetailsButton.pcss +++ b/res/css/components/views/settings/devices/_DeviceExpandDetailsButton.pcss @@ -20,7 +20,7 @@ limitations under the License. background: transparent; border-radius: 4px; - color: $secondary-content; + color: $icon-button-color; --icon-transform: rotate(-90deg); diff --git a/res/css/components/views/settings/devices/_DeviceSecurityCard.pcss b/res/css/components/views/settings/devices/_DeviceSecurityCard.pcss index c2a0d5bb780..2e4055cfd49 100644 --- a/res/css/components/views/settings/devices/_DeviceSecurityCard.pcss +++ b/res/css/components/views/settings/devices/_DeviceSecurityCard.pcss @@ -53,7 +53,7 @@ limitations under the License. &.Inactive { --icon-color: $secondary-content; - --background-color: $system; + --background-color: $panels; } } @@ -65,7 +65,7 @@ limitations under the License. } .mx_DeviceSecurityCard_description { margin: 0; - font-size: $font-12px; + font: var(--cpd-font-body-sm-regular); color: $secondary-content; } diff --git a/res/css/components/views/settings/devices/_DeviceTile.pcss b/res/css/components/views/settings/devices/_DeviceTile.pcss index 18224362c22..7bfe1e0349c 100644 --- a/res/css/components/views/settings/devices/_DeviceTile.pcss +++ b/res/css/components/views/settings/devices/_DeviceTile.pcss @@ -31,9 +31,8 @@ limitations under the License. .mx_DeviceTile_metadata { margin-top: $spacing-4; - font-size: $font-12px; + font: var(--cpd-font-body-sm-regular); color: $secondary-content; - line-height: $font-14px; } .mx_DeviceTile_inactiveIcon { diff --git a/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss b/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss index 5a5937b1513..5bd1c29ed59 100644 --- a/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss +++ b/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss @@ -23,7 +23,7 @@ limitations under the License. } .mx_DeviceTypeIcon_deviceIconWrapper { - --background-color: $system; + --background-color: $panels; --icon-color: $secondary-content; height: 40px; diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss index 9f9bd0cc712..857e56e34c8 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/devices/_FilteredDeviceListHeader.pcss b/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss index 3bba9d90b35..8108624024a 100644 --- a/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss @@ -26,9 +26,14 @@ limitations under the License. padding: 0 $spacing-16; margin-bottom: $spacing-32; - background-color: $system; + background-color: $panels; border-radius: 8px; color: $secondary-content; + + /* Higher specificity selector to override the flex-start value */ + .mx_AccessibleButton.mx_AccessibleButton_hasKind { + align-self: center; + } } .mx_FilteredDeviceListHeader_label { diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index 2d8894150f0..44d0a344264 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -26,7 +26,6 @@ limitations under the License. .mx_SettingsSubsection_text { width: 100%; box-sizing: inherit; - font-size: $font-15px; color: $secondary-content; } @@ -34,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/components/views/spaces/_QuickThemeSwitcher.pcss b/res/css/components/views/spaces/_QuickThemeSwitcher.pcss index a729134c124..66a97313538 100644 --- a/res/css/components/views/spaces/_QuickThemeSwitcher.pcss +++ b/res/css/components/views/spaces/_QuickThemeSwitcher.pcss @@ -30,7 +30,7 @@ limitations under the License. } .mx_QuickThemeSwitcher_heading { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-12px; line-height: $font-15px; color: $secondary-content; diff --git a/res/css/components/views/typography/_Caption.pcss b/res/css/components/views/typography/_Caption.pcss index f51276d9f96..cad93f3881a 100644 --- a/res/css/components/views/typography/_Caption.pcss +++ b/res/css/components/views/typography/_Caption.pcss @@ -15,7 +15,7 @@ limitations under the License. */ .mx_Caption { - font-size: $font-12px; + font: var(--cpd-font-body-sm-regular); color: $secondary-content; &.mx_Caption_error { diff --git a/res/css/components/views/utils/_Box.pcss b/res/css/components/views/utils/_Box.pcss new file mode 100644 index 00000000000..a8ab7e94557 --- /dev/null +++ b/res/css/components/views/utils/_Box.pcss @@ -0,0 +1,27 @@ +/* +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. +*/ + +.mx_Box--flex { + flex: var(--mx-box-flex, unset); +} + +.mx_Box--shrink { + flex-shrink: var(--mx-box-shrink, unset); +} + +.mx_Box--grow { + flex-grow: var(--mx-box-grow, unset); +} diff --git a/res/css/components/views/utils/_Flex.pcss b/res/css/components/views/utils/_Flex.pcss new file mode 100644 index 00000000000..f9cdc7e3cc4 --- /dev/null +++ b/res/css/components/views/utils/_Flex.pcss @@ -0,0 +1,23 @@ +/* +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. +*/ + +.mx_Flex { + display: var(--mx-flex-display, unset); + flex-direction: var(--mx-flex-direction, unset); + align-items: var(--mx-flex-align, unset); + justify-content: var(--mx-flex-justify, unset); + gap: var(--mx-flex-gap, unset); +} diff --git a/res/css/compound/_Icon.pcss b/res/css/compound/_Icon.pcss index 07f9eb5a0e7..185fb24f3ce 100644 --- a/res/css/compound/_Icon.pcss +++ b/res/css/compound/_Icon.pcss @@ -29,7 +29,7 @@ limitations under the License. } .mx_Icon_bg-accent-light { - background-color: rgba($accent, 0.1); + background-color: $accent-300; } .mx_Icon_alert { diff --git a/res/css/compound/_SuccessDialog.pcss b/res/css/compound/_SuccessDialog.pcss index 61f98a97df7..9085cedc11b 100644 --- a/res/css/compound/_SuccessDialog.pcss +++ b/res/css/compound/_SuccessDialog.pcss @@ -18,7 +18,7 @@ limitations under the License. text-align: center; .mx_Icon { - mask-border: $spacing-16; + margin-bottom: $spacing-16; } .mx_Dialog_header { diff --git a/res/css/structures/_AutocompleteInput.pcss b/res/css/structures/_AutocompleteInput.pcss index 754c8ae1944..5e799c95a7e 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/_ContextualMenu.pcss b/res/css/structures/_ContextualMenu.pcss index cac926ec724..eeb066fd4e5 100644 --- a/res/css/structures/_ContextualMenu.pcss +++ b/res/css/structures/_ContextualMenu.pcss @@ -30,12 +30,12 @@ limitations under the License. } .mx_ContextualMenu { - border-radius: 8px; - box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; - background-color: $menu-bg-color; + border-radius: 12px; + box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1); + background-color: var(--cpd-color-bg-canvas-default); + border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary); color: $primary-content; position: absolute; - font-size: $font-14px; z-index: 5001; width: max-content; } diff --git a/res/css/structures/_FilePanel.pcss b/res/css/structures/_FilePanel.pcss index 99de3d32965..1c80cde9013 100644 --- a/res/css/structures/_FilePanel.pcss +++ b/res/css/structures/_FilePanel.pcss @@ -52,16 +52,6 @@ limitations under the License. padding-inline-start: 0; } - &:hover { - &.mx_EventTile_verified, - &.mx_EventTile_unverified, - &.mx_EventTile_unknown { - .mx_EventTile_line { - box-shadow: none; - } - } - } - .mx_MFileBody { line-height: 2.4rem; } @@ -70,11 +60,11 @@ limitations under the License. padding-top: $spacing-8; display: flex; justify-content: space-between; - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); color: $event-timestamp-color; .mx_MImageBody_size { - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); text-align: right; white-space: nowrap; } @@ -100,7 +90,8 @@ limitations under the License. .mx_MessageTimestamp { text-align: right; - font-size: $font-14px; + color: $secondary-content; + font: var(--cpd-font-body-sm-regular); } } } diff --git a/res/css/structures/_GenericDropdownMenu.pcss b/res/css/structures/_GenericDropdownMenu.pcss index c3740cc847d..1722c7fd2e1 100644 --- a/res/css/structures/_GenericDropdownMenu.pcss +++ b/res/css/structures/_GenericDropdownMenu.pcss @@ -92,7 +92,7 @@ limitations under the License. span:first-child { color: $primary-content; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); } } diff --git a/res/css/structures/_HomePage.pcss b/res/css/structures/_HomePage.pcss index b2495634357..b2f607f8226 100644 --- a/res/css/structures/_HomePage.pcss +++ b/res/css/structures/_HomePage.pcss @@ -37,15 +37,15 @@ limitations under the License. } h1 { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-32px; - line-height: $font-44px; + line-height: 1.375; margin-bottom: 4px; } h2 { margin-top: 4px; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-18px; line-height: $font-25px; color: $muted-fg-color; @@ -73,7 +73,7 @@ limitations under the License. word-break: break-word; box-sizing: border-box; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-15px; line-height: $font-20px; color: #fff; /* on all themes */ diff --git a/res/css/structures/_LargeLoader.pcss b/res/css/structures/_LargeLoader.pcss index 555eb4bee55..55f57b02942 100644 --- a/res/css/structures/_LargeLoader.pcss +++ b/res/css/structures/_LargeLoader.pcss @@ -29,7 +29,7 @@ limitations under the License. .mx_LargeLoader_text { font-size: 24px; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); padding: 0 16px; position: relative; text-align: center; diff --git a/res/css/structures/_LeftPanel.pcss b/res/css/structures/_LeftPanel.pcss index 95ca5c350db..9cbffc77d4a 100644 --- a/res/css/structures/_LeftPanel.pcss +++ b/res/css/structures/_LeftPanel.pcss @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -$roomListCollapsedWidth: 68px; - .mx_MatrixChat--with-avatar { .mx_LeftPanel, .mx_LeftPanel .mx_LeftPanel_roomListContainer { @@ -33,6 +31,11 @@ $roomListCollapsedWidth: 68px; contain: layout paint; } +.mx_LeftPanel_wrapper, +.mx_LeftPanel { + --collapsedWidth: 68px; +} + .mx_LeftPanel_wrapper { display: flex; flex-direction: row; @@ -46,7 +49,7 @@ $roomListCollapsedWidth: 68px; position: relative; &[data-collapsed] { - max-width: $roomListCollapsedWidth; + max-width: var(--collapsedWidth); } } } @@ -218,7 +221,7 @@ $roomListCollapsedWidth: 68px; width: unset !important; .mx_LeftPanel_roomListContainer { - width: $roomListCollapsedWidth; + width: var(--collapsedWidth); .mx_LeftPanel_userHeader { flex-direction: row; diff --git a/res/css/structures/_MainSplit.pcss b/res/css/structures/_MainSplit.pcss index 55e0dec1034..e188e881eff 100644 --- a/res/css/structures/_MainSplit.pcss +++ b/res/css/structures/_MainSplit.pcss @@ -23,17 +23,11 @@ limitations under the License. } .mx_MainSplit > .mx_RightPanel_ResizeWrapper { - padding: var(--container-gap-width); - /* The resizer should be centered: only half of the gap-width is handled by the right panel. */ - /* The other half by the RoomView. */ - padding-left: calc(var(--container-gap-width) / 2); - height: calc(100vh - 51px); /* height of .mx_RoomHeader.light-panel */ - &:hover .mx_ResizeHandle--horizontal::before { position: absolute; top: 50%; left: 50%; - transform: translate(-50%, -50%); + transform: translate(-150%, -50%); height: 64px; /* to match width of the ones on roomlist */ width: 4px; diff --git a/res/css/structures/_MatrixChat.pcss b/res/css/structures/_MatrixChat.pcss index c09d32f491f..cdbe2fcfefc 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; @@ -76,24 +57,6 @@ limitations under the License. height: 100%; } -/* We'd like to remove this, but this makes matrixchat's resizehandle's */ -/* negative margin greater than its positive padding. If it's the same */ -/* or less, Safari and other WebKit based browsers get confused about overflows somehow and */ -/* https://github.com/vector-im/element-web/issues/19863 happens. */ -.mx_MatrixChat > .mx_ResizeHandle.mx_ResizeHandle--horizontal { - margin: 0 calc(-5.5px - var(--container-gap-width) / 2) 0 calc(-6.5px + var(--container-gap-width) / 2); - /* The condition to prevent bleeding is: (margin-left + margin-right < -11px) */ - /* (IF there is NO margin on the leftPanel_wrapper) */ - /* The resizeHandle does not change the gap between the left panel and the room view: */ - /* the resizeHandle width is: */ - /* 11px = 10px (padding) + 1px (width) */ - /* and the total negative margin is -12px -> */ - /* the handle requires no space */ - /* right: -6px left: -6px positions the element exactly on the edge of leftPanel. */ - /* left+=1 and right-=1 => resizeHandle moves 1px to the right closer to the center of the gap. */ - /* We want the handle to be in the middle of the gap so it is shifted by (var(--container-gap-width) / 2) */ -} - .mx_MatrixChat > .mx_ResizeHandle--horizontal:hover { position: relative; diff --git a/res/css/structures/_MessagePanel.pcss b/res/css/structures/_MessagePanel.pcss index c5e777b3e6d..487f6dd801f 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 3f26e132504..569effd3aed 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; } @@ -59,10 +59,10 @@ limitations under the License. contain: unset; /* let the dropdown paint beyond the context menu */ > div > h2 { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-15px; line-height: $font-24px; - color: $primary-content; + color: var(--cpd-color-text-secondary); margin: 0 0 16px; } @@ -72,11 +72,11 @@ limitations under the License. } > div > h4 { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-12px; line-height: $font-15px; text-transform: uppercase; - color: $tertiary-content; + color: var(--cpd-color-text-secondary); margin: 20px 0 12px; } @@ -97,7 +97,7 @@ limitations under the License. margin-left: 6px; font-size: $font-15px; line-height: $font-24px; - color: $secondary-content; + color: var(--cpd-color-text-primary); } } @@ -106,7 +106,7 @@ limitations under the License. margin-left: 22px; font-size: $font-15px; line-height: $font-24px; - color: $secondary-content; + color: var(--cpd-color-text-primary); position: relative; margin-bottom: 16px; } diff --git a/res/css/structures/_RightPanel.pcss b/res/css/structures/_RightPanel.pcss index 71c98607641..f8b5cb44085 100644 --- a/res/css/structures/_RightPanel.pcss +++ b/res/css/structures/_RightPanel.pcss @@ -21,11 +21,11 @@ limitations under the License. position: relative; display: flex; flex-direction: column; - border-radius: 8px; - padding: var(--container-border-width) 0; + border-left: 1px solid $separator; box-sizing: border-box; height: 100%; contain: strict; + background-color: var(--cpd-color-bg-canvas-default); .mx_RoomView_MessageList { padding: 14px 18px; /* top and bottom is 4px smaller to balance with the padding set above */ @@ -83,7 +83,7 @@ limitations under the License. h2, p { - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); } &::before { @@ -99,20 +99,3 @@ limitations under the License. mask-position: center; } } - -.mx_RightPanel_scopeHeader { - margin: 24px; - text-align: center; - font-weight: var(--font-semi-bold); - font-size: $font-18px; - line-height: $font-22px; - - .mx_BaseAvatar { - margin-right: 8px; - vertical-align: middle; - } - - .mx_BaseAvatar_image { - border-radius: 8px; - } -} diff --git a/res/css/structures/_RoomSearch.pcss b/res/css/structures/_RoomSearch.pcss index 8252d2d9b9a..fea292c8baf 100644 --- a/res/css/structures/_RoomSearch.pcss +++ b/res/css/structures/_RoomSearch.pcss @@ -43,15 +43,13 @@ limitations under the License. } .mx_RoomSearch_spotlightTriggerText { - font-size: $font-12px; - line-height: $font-16px; color: $tertiary-content; flex: 1; min-width: 0; /* the following rules are to match that of a real input field */ overflow: hidden; margin: 9px; - font-weight: var(--font-semi-bold); + font: var(--cpd-font-body-sm-semibold); } .mx_RoomSearch_shortcutPrompt { @@ -62,7 +60,7 @@ limitations under the License. font-size: $font-12px; line-height: $font-15px; font-family: inherit; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); color: $light-fg-color; margin-right: 6px; white-space: nowrap; diff --git a/res/css/structures/_RoomStatusBar.pcss b/res/css/structures/_RoomStatusBar.pcss index 60191c34528..d0bfa9f7f5d 100644 --- a/res/css/structures/_RoomStatusBar.pcss +++ b/res/css/structures/_RoomStatusBar.pcss @@ -25,16 +25,6 @@ limitations under the License. text-align: left; } -.mx_RoomStatusBar_typingIndicatorAvatars .mx_BaseAvatar_image { - margin-right: -12px; - border: 1px solid $background; -} - -.mx_RoomStatusBar_typingIndicatorAvatars .mx_BaseAvatar_initial { - padding-left: 1px; - padding-top: 1px; -} - .mx_RoomStatusBar_typingIndicatorRemaining { display: inline-block; color: #acacac; @@ -186,7 +176,7 @@ limitations under the License. .mx_RoomStatusBar_typingBar { height: 50px; - line-height: $font-50px; + line-height: 50px; color: $primary-content; opacity: 0.5; @@ -205,6 +195,6 @@ limitations under the License. .mx_RoomStatusBar_typingBar { height: 40px; - line-height: $font-40px; + line-height: 40px; } } diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index b340c8d9945..30583384b7a 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -15,7 +15,7 @@ limitations under the License. */ :root { - --RoomView_MessageList-padding: 18px; /* TODO: use a variable */ + --RoomView_MessageList-padding: 18px; } .mx_RoomView_wrapper { @@ -190,7 +190,7 @@ limitations under the License. } /* Rooms with immersive content */ -.mx_RoomView_immersive .mx_RoomHeader_wrapper { +.mx_RoomView_immersive .mx_LegacyRoomHeader_wrapper { border: unset; } @@ -212,7 +212,7 @@ limitations under the License. margin-bottom: 4px; h2 { - margin-top: 6px; /* TODO: Use a spacing variable */ + margin-top: 6px; } } @@ -258,8 +258,8 @@ limitations under the License. height: var(--RoomHeader-indicator-dot-size); border-radius: 50%; transform: scale(1); - background: rgba(var(--RoomHeader-indicator-pulseColor), 1); - box-shadow: 0 0 0 0 rgba(var(--RoomHeader-indicator-pulseColor), 1); + background: var(--RoomHeader-indicator-pulseColor); + box-shadow: 0 0 0 0 var(--RoomHeader-indicator-pulseColor); animation: mx_Indicator_pulse 2s infinite; animation-iteration-count: 1; diff --git a/res/css/structures/_SpaceHierarchy.pcss b/res/css/structures/_SpaceHierarchy.pcss index 2dedf2099c9..ca129fdbac9 100644 --- a/res/css/structures/_SpaceHierarchy.pcss +++ b/res/css/structures/_SpaceHierarchy.pcss @@ -46,7 +46,7 @@ limitations under the License. .mx_SpaceHierarchy_listHeader_header { grid-column-start: 1; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); margin: 0; } @@ -71,7 +71,7 @@ limitations under the License. .mx_SpaceHierarchy_error { position: relative; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); color: $alert; font-size: $font-15px; line-height: $font-18px; @@ -94,7 +94,7 @@ limitations under the License. .mx_SpaceHierarchy_roomCount { > h3 { display: inline; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-18px; line-height: $font-22px; color: $primary-content; @@ -108,12 +108,6 @@ limitations under the License. } } - .mx_SpaceHierarchy_subspace { - .mx_BaseAvatar_image { - border-radius: 8px; - } - } - .mx_SpaceHierarchy_subspace_toggle { position: absolute; left: -1px; @@ -167,7 +161,7 @@ limitations under the License. gap: 6px 12px; .mx_SpaceHierarchy_roomTile_item { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-15px; line-height: $font-18px; display: grid; @@ -233,7 +227,7 @@ limitations under the License. .mx_SpaceHierarchy_roomTile_info { grid-row: 2; grid-column: 2; - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); font-weight: initial; line-height: $font-18px; color: $secondary-content; diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 73f6fde570a..d685617d5b3 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -14,15 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -$topLevelHeight: 32px; -$nestedHeight: 24px; -$gutterSize: 16px; -$activeBorderTransparentGap: 1px; - -$activeBackgroundColor: $panel-actions; -$activeBorderColor: $primary-content; - .mx_SpacePanel { + --activeBackground-color: $panel-actions; + --activeBorder-color: $primary-content; + --activeBorder-transparent-gap: 1px; + --gutterSize: 14px; + --height-nested: 24px; + --height-topLevel: 32px; + background-color: $spacePanel-bg-color; flex: 0 0 auto; padding: 0; @@ -35,6 +34,10 @@ $activeBorderColor: $primary-content; display: flex; flex-direction: column; + &.collapsed { + width: 68px; + } + .mx_SpacePanel_toggleCollapse { position: absolute; width: 18px; @@ -116,7 +119,7 @@ $activeBorderColor: $primary-content; } .mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton { - margin-left: $gutterSize; + margin-left: var(--gutterSize); min-width: 40px; } @@ -130,12 +133,12 @@ $activeBorderColor: $primary-content; &.mx_SpaceButton_active { &:not(.mx_SpaceButton_narrow) .mx_SpaceButton_selectionWrapper { - background-color: $activeBackgroundColor; + background-color: var(--activeBackground-color); } &.mx_SpaceButton_narrow .mx_SpaceButton_selectionWrapper { - padding: $activeBorderTransparentGap; - border: 3px $activeBorderColor solid; + padding: var(--activeBorder-transparent-gap); + border: 3px var(--activeBorder-color) solid; } } @@ -150,6 +153,11 @@ $activeBorderColor: $primary-content; min-width: 0; } + &.mx_SpaceButton_narrow .mx_SpaceButton_selectionWrapper { + flex: initial; + width: 32px; + } + .mx_SpaceButton_name { flex: 1; margin-left: 8px; @@ -157,14 +165,13 @@ $activeBorderColor: $primary-content; display: block; text-overflow: ellipsis; overflow: hidden; - font-size: $font-14px; - line-height: $font-18px; + font: var(--cpd-font-body-md-regular); } .mx_SpaceButton_toggleCollapse { - width: $gutterSize; + width: var(--gutterSize); padding: 10px 0; - min-width: $gutterSize; + min-width: var(--gutterSize); height: 20px; mask-position: center; mask-size: 20px; @@ -174,17 +181,17 @@ $activeBorderColor: $primary-content; } .mx_SpaceButton_icon { - width: $topLevelHeight; - min-width: $topLevelHeight; - height: $topLevelHeight; + width: var(--height-topLevel); + min-width: var(--height-topLevel); + height: var(--height-topLevel); border-radius: 8px; position: relative; &::before { position: absolute; content: ""; - width: $topLevelHeight; - height: $topLevelHeight; + width: var(--height-topLevel); + height: var(--height-topLevel); top: 0; left: 0; mask-position: center; @@ -196,7 +203,8 @@ $activeBorderColor: $primary-content; &.mx_SpaceButton_home, &.mx_SpaceButton_favourites, &.mx_SpaceButton_people, - &.mx_SpaceButton_orphans { + &.mx_SpaceButton_orphans, + &.mx_SpaceButton_videoRooms { .mx_SpaceButton_icon { background-color: $panel-actions; @@ -222,6 +230,10 @@ $activeBorderColor: $primary-content; mask-image: url("$(res)/img/element-icons/roomlist/hash-circle.svg"); } + &.mx_SpaceButton_videoRooms .mx_SpaceButton_icon::before { + mask-image: url("@vector-im/compound-design-tokens/icons/video-call-solid.svg"); + } + &.mx_SpaceButton_new .mx_SpaceButton_icon { &::before { background-color: $primary-content; @@ -234,10 +246,6 @@ $activeBorderColor: $primary-content; transform: rotate(45deg); } - .mx_BaseAvatar_image { - border-radius: 8px; - } - .mx_SpaceButton_menuButton { width: 20px; min-width: 20px; /* yay flex */ @@ -271,27 +279,15 @@ $activeBorderColor: $primary-content; min-width: 0; flex-grow: 1; - .mx_BaseAvatar:not(.mx_UserMenu_userAvatar_BaseAvatar) .mx_BaseAvatar_initial { - color: $secondary-content; - border-radius: 8px; - background-color: $panel-actions; - font-size: $font-15px !important; /* override inline style */ - font-weight: var(--font-semi-bold); - line-height: $font-18px; - - & + .mx_BaseAvatar_image { - visibility: hidden; - } - } - .mx_SpaceTreeLevel { - // Indent subspaces + /* Indent subspaces */ padding-left: 16px; } } .mx_SpaceButton_avatarWrapper { position: relative; + line-height: 0; } .mx_SpacePanel_badgeContainer { @@ -341,13 +337,14 @@ $activeBorderColor: $primary-content; /* root space buttons are bigger and not indented */ & > .mx_AutoHideScrollbar { flex: 1; - padding: 0 8px 16px 0; + padding: 0 0 16px 0; + scrollbar-gutter: stable; & > .mx_SpaceButton { - height: $topLevelHeight; + height: var(--height-topLevel); &.mx_SpaceButton_active::before { - height: $topLevelHeight; + height: var(--height-topLevel); } } @@ -356,22 +353,58 @@ $activeBorderColor: $primary-content; } &.mx_IndicatorScrollbar_topOverflow { - mask-image: linear-gradient(180deg, transparent, black 5%); + mask-image: linear-gradient(to bottom, transparent, black 16px); } &.mx_IndicatorScrollbar_bottomOverflow { - mask-image: linear-gradient(180deg, black, black 95%, transparent); + mask-image: linear-gradient( + to top, + transparent, + rgba(255, 255, 255, 30%) 4px, + rgba(255, 255, 255, 55%) 8px, + rgba(255, 255, 255, 75%) 12px, + black 16px + ); } &.mx_IndicatorScrollbar_topOverflow.mx_IndicatorScrollbar_bottomOverflow { - mask-image: linear-gradient(180deg, transparent, black 5%, black 95%, transparent); + /* This stacks two gradients on top of one another, which lets us + have a fixed pixel offset from both top and bottom for the colour stops. + Note the top fade is much smaller because the spaces start close to the top, + so otherwise a large gradient suddenly appears when you scroll down. + */ + mask-image: linear-gradient(to bottom, transparent, black 16px), + linear-gradient( + to top, + transparent, + rgba(255, 255, 255, 30%) 4px, + rgba(255, 255, 255, 55%) 8px, + rgba(255, 255, 255, 75%) 12px, + black 16px + ); + mask-position: + 0% 0%, + 0% 100%; + mask-size: + calc(100% - 10px) 50%, + calc(100% - 10px) 50%; + mask-repeat: no-repeat; } } .mx_UserMenu { - padding: 0 2px 8px; - border-bottom: 1px solid $quinary-content; + padding-bottom: 12px; + border-bottom: 1px solid $separator; margin: 12px 14px 4px 18px; + width: min-content; + max-width: 226px; + + /* Display the container and img here as block elements so they don't take + * up extra vertical space. + */ + .mx_UserMenu_userAvatar_BaseAvatar { + display: block; + } } } @@ -380,7 +413,7 @@ $activeBorderColor: $primary-content; .mx_SpacePanel_contextMenu_header { margin: 12px 16px 12px; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-15px; line-height: $font-18px; overflow: hidden; @@ -432,11 +465,17 @@ $activeBorderColor: $primary-content; color: $tertiary-content; font-size: $font-10px; line-height: $font-12px; - font-weight: var(--font-semi-bold); - //margin-left: 8px; + font-weight: var(--cpd-font-weight-semibold); } } .mx_SpacePanel_sharePublicSpace { margin: 0; } + +.mx_SpacePanel_Tooltip_KeyboardShortcut { + kbd { + font-family: inherit; + text-transform: capitalize; + } +} diff --git a/res/css/structures/_SpaceRoomView.pcss b/res/css/structures/_SpaceRoomView.pcss index 5f434a28022..50f6fc92783 100644 --- a/res/css/structures/_SpaceRoomView.pcss +++ b/res/css/structures/_SpaceRoomView.pcss @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -$SpaceRoomViewInnerWidth: 428px; - @define-mixin SpacePillButton { position: relative; padding: 16px 32px 16px 72px; @@ -24,7 +22,7 @@ $SpaceRoomViewInnerWidth: 428px; border-radius: 8px; border: 1px solid $input-border-color; font-size: $font-17px; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); margin: 20px 0; > div { @@ -48,10 +46,10 @@ $SpaceRoomViewInnerWidth: 428px; } &:hover { - border-color: $accent; + border-color: var(--cpd-color-bg-interactive-primary-rest); &::before { - background-color: $accent; + background-color: var(--cpd-color-icon-primary); } > span { @@ -61,6 +59,8 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView { + --innerWidth: 428px; + overflow-y: auto; flex: 1; @@ -73,7 +73,7 @@ $SpaceRoomViewInnerWidth: 428px; h1 { margin: 0; font-size: $font-24px; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); color: $primary-content; width: max-content; } @@ -83,11 +83,11 @@ $SpaceRoomViewInnerWidth: 428px; color: $secondary-content; margin-top: 12px; margin-bottom: 24px; - max-width: $SpaceRoomViewInnerWidth; + max-width: var(--innerWidth); } .mx_AddExistingToSpace { - max-width: $SpaceRoomViewInnerWidth; + max-width: var(--innerWidth); .mx_AddExistingToSpace_content { height: calc(100vh - 360px); @@ -98,7 +98,7 @@ $SpaceRoomViewInnerWidth: 428px; .mx_SpaceRoomView_buttons { display: block; margin-top: 44px; - width: $SpaceRoomViewInnerWidth; + width: var(--innerWidth); text-align: right; /* button alignment right */ .mx_AccessibleButton_hasKind { @@ -112,7 +112,7 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_Field { - max-width: $SpaceRoomViewInnerWidth; + max-width: var(--innerWidth); & + .mx_Field { margin-top: 28px; @@ -120,7 +120,7 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView_errorText { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-12px; line-height: $font-15px; color: $alert; @@ -143,10 +143,6 @@ $SpaceRoomViewInnerWidth: 428px; .mx_BaseAvatar { width: 80px; - - .mx_BaseAvatar_image { - border-radius: 12px; - } } } @@ -193,10 +189,7 @@ $SpaceRoomViewInnerWidth: 428px; .mx_FacePile { display: inline-block; - - .mx_FacePile_faces { - cursor: pointer; - } + cursor: pointer; } .mx_SpaceRoomView_landing_inviteButton, @@ -219,7 +212,7 @@ $SpaceRoomViewInnerWidth: 428px; left: 8px; height: 16px; width: 16px; - background: #fff; /* white icon fill */ + background: var(--cpd-color-icon-on-solid-primary); mask-size: 16px; mask-image: url("$(res)/img/element-icons/room/invite.svg"); } @@ -300,11 +293,13 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView_inviteTeammates_inviteDialogButton { - color: $accent; + color: var(--cpd-color-text-primary); + font-weight: var(--cpd-font-weight-semibold); + text-decoration: underline; &::before { mask-image: url("$(res)/img/element-icons/room/invite.svg"); - background-color: $accent; + background-color: var(--cpd-color-icon-primary); } } } diff --git a/res/css/structures/_SplashPage.pcss b/res/css/structures/_SplashPage.pcss index be87b04c78d..58c462a226c 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/_TabbedView.pcss b/res/css/structures/_TabbedView.pcss index fd8d6a63ffc..04f0587b0a8 100644 --- a/res/css/structures/_TabbedView.pcss +++ b/res/css/structures/_TabbedView.pcss @@ -18,7 +18,7 @@ limitations under the License. .mx_TabbedView { margin: 0; - padding: 0 0 0 16px; + padding: 0 0 0 var(--cpd-space-8x); display: flex; flex-direction: column; inset: 0; @@ -30,37 +30,42 @@ limitations under the License. position: absolute; .mx_TabbedView_tabLabels { - width: 170px; - max-width: 170px; + width: 220px; + max-width: 220px; position: fixed; margin: 0; /* Remove the default value */ padding: 0; /* Remove the default value */ } .mx_TabbedView_tabPanel { - margin-left: 240px; /* 170px sidebar + 70px padding */ + margin-left: 280px; /* 220px sidebar + 60px padding */ flex-direction: column; } + .mx_TabbedView_tabLabel:hover, .mx_TabbedView_tabLabel_active { - background-color: $accent; color: $tab-label-active-fg-color; + + .mx_TabbedView_maskedIcon::before { + background-color: var(--cpd-color-icon-primary); + } } - .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { - background-color: $tab-label-active-fg-color; + .mx_TabbedView_tabLabel_active { + background-color: var(--cpd-color-bg-subtle-secondary); } .mx_TabbedView_maskedIcon { - width: 16px; - height: 16px; - margin-right: 16px; + width: 20px; + height: 20px; + margin-right: var(--cpd-space-3x); } .mx_TabbedView_maskedIcon::before { - mask-size: 16px; - width: 16px; - height: 16px; + mask-size: 20px; + width: 20px; + height: 20px; + transition: background-color 0.1s; } } @@ -120,10 +125,17 @@ limitations under the License. align-items: center; vertical-align: text-top; cursor: pointer; - padding: 8px; - border-radius: 8px; - font-size: $font-13px; + padding-block: var(--cpd-space-2x); + padding-inline: var(--cpd-space-3x) var(--cpd-space-4x); + box-sizing: border-box; + min-block-size: 40px; + min-inline-size: 40px; + border-radius: 24px; + font: var(--cpd-font-body-md-medium); position: relative; + transition: + color 0.1s, + background-color 0.1s; } .mx_TabbedView_maskedIcon { @@ -132,7 +144,7 @@ limitations under the License. .mx_TabbedView_maskedIcon::before { display: inline-block; - background-color: $icon-button-color; + background-color: var(--cpd-color-icon-secondary); mask-repeat: no-repeat; mask-position: center; content: ""; @@ -153,3 +165,25 @@ limitations under the License. overflow: auto; min-height: 0; /* firefox */ } + +/* Hide the labels on tabs, showing only the icons, on narrow viewports. */ +@media (max-width: 1024px) { + .mx_TabbedView_tabsOnLeft.mx_TabbedView_responsive { + .mx_TabbedView_tabLabel_text { + display: none; + } + .mx_TabbedView_tabPanel { + margin-left: 72px; /* 40px sidebar + 32px padding */ + } + .mx_TabbedView_maskedIcon { + margin-right: auto; + margin-left: auto; + } + .mx_TabbedView_tabLabels { + width: auto; + } + .mx_TabbedView_tabLabel { + padding-inline: 0 0; + } + } +} diff --git a/res/css/structures/_ThreadsActivityCentre.pcss b/res/css/structures/_ThreadsActivityCentre.pcss new file mode 100644 index 00000000000..76b38d6c076 --- /dev/null +++ b/res/css/structures/_ThreadsActivityCentre.pcss @@ -0,0 +1,94 @@ +/* + * + * Copyright 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_ThreadsActivityCentre_container { + display: flex; +} + +.mx_ThreadsActivityCentreButton { + border-radius: 8px; + margin: 18px auto auto auto; + + &.expanded { + /** + * override compound default background color when hovered + * should disappear when the space panel will be migrated to compound + */ + background-color: transparent !important; + + /* align with settings icon */ + margin-left: 21px; + + /** + * modify internal css of the compound component + * dirty but we need to add the `Threads` label into the indicator icon button + **/ + & > div { + display: flex; + align-items: center; + } + + & .mx_ThreadsActivityCentreButton_Icon { + /* align with settings label */ + margin-right: 14px; + /* required to set the icon width when into a flex container */ + min-width: 24px; + } + + & .mx_ThreadsActivityCentreButton_Text { + color: $secondary-content; + } + } + + &:not(.expanded) { + &:hover, + &:hover .mx_ThreadsActivityCentreButton_Icon { + background-color: $quaternary-content; + color: $primary-content; + } + } + + & .mx_ThreadsActivityCentreButton_Icon { + color: $secondary-content; + } +} + +.mx_ThreadsActivityCentre_rows { + overflow-y: scroll; + /* Let some space at the top and the bottom of the pop-up */ + max-height: calc(100vh - 200px); + + .mx_ThreadsActivityCentreRow { + height: 48px; + + /* Make the label of the MenuItem stay on one line and truncate with ellipsis if needed */ + & > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + /* Arbitrary size, keep the TAC as the wanted width */ + width: 202px; + } + } +} + +.mx_ThreadsActivityCentre_emptyCaption { + padding-left: 16px; + padding-right: 16px; + font-size: 13px; +} diff --git a/res/css/structures/_ToastContainer.pcss b/res/css/structures/_ToastContainer.pcss index c33caf27583..6b18836776f 100644 --- a/res/css/structures/_ToastContainer.pcss +++ b/res/css/structures/_ToastContainer.pcss @@ -36,16 +36,17 @@ limitations under the License. .mx_Toast_toast { grid-row: 1 / 3; grid-column: 1; - background-color: $system; + background-color: var(--cpd-color-bg-canvas-default); color: $primary-content; - box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); - border-radius: 8px; + box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1); + border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary); + border-radius: 12px; overflow: hidden; display: grid; grid-template-columns: 22px 1fr; column-gap: 8px; row-gap: 4px; - padding: 8px; + padding: var(--cpd-space-3x); &.mx_Toast_hasIcon { &::before, @@ -118,8 +119,8 @@ limitations under the License. h2 { margin: 0; - font-size: $font-15px; - font-weight: var(--font-semi-bold); + font: var(--cpd-font-heading-sm-medium); + font-weight: var(--cpd-font-weight-medium); display: inline; width: auto; } @@ -153,7 +154,7 @@ limitations under the License. overflow: hidden; text-overflow: ellipsis; margin: 4px 0 11px 0; - font-size: $font-12px; + font: var(--cpd-font-body-sm-regular); a { text-decoration: none; diff --git a/res/css/structures/_UploadBar.pcss b/res/css/structures/_UploadBar.pcss index a7dfc8b74fd..a0689d4270e 100644 --- a/res/css/structures/_UploadBar.pcss +++ b/res/css/structures/_UploadBar.pcss @@ -16,6 +16,7 @@ limitations under the License. .mx_UploadBar { padding-left: 65px; /* line up with the shield area in the composer */ + padding-top: 5px; position: relative; .mx_ProgressBar { @@ -30,9 +31,9 @@ limitations under the License. } .mx_UploadBar_filename { - margin-top: 5px; color: $muted-fg-color; position: relative; + padding-right: 38px; /* 32px for cancel icon, 6px for padding */ padding-left: 22px; /* 18px for icon, 4px for padding */ font-size: $font-15px; vertical-align: middle; @@ -58,6 +59,7 @@ limitations under the License. height: 16px; width: 16px; margin-right: 16px; /* align over rightmost button in composer */ + margin-top: 5px; mask-repeat: no-repeat; mask-position: center; background-color: $muted-fg-color; diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index 4c23bf23c0d..5f8a6a70a1b 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -46,8 +46,15 @@ limitations under the License. } } + .mx_UserMenu_contextMenuButton { + width: 100%; + } + .mx_UserMenu_name { - font-weight: var(--font-semi-bold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: var(--cpd-font-weight-semibold); font-size: $font-15px; line-height: $font-24px; margin-left: 10px; @@ -104,7 +111,7 @@ limitations under the License. .mx_UserMenu_contextMenu_displayName, .mx_UserMenu_contextMenu_userId { - font-size: $font-15px; + font: var(--cpd-font-heading-sm-regular); /* Automatically grow subelements to fit the container */ flex: 1; @@ -117,12 +124,7 @@ limitations under the License. } .mx_UserMenu_contextMenu_displayName { - font-weight: bold; - line-height: $font-20px; - } - - .mx_UserMenu_contextMenu_userId { - line-height: $font-24px; + font-weight: var(--cpd-font-weight-semibold); } } @@ -147,7 +149,7 @@ limitations under the License. display: inline-block; > span { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); display: block; & + span { @@ -170,7 +172,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $primary-content; + background: $icon-button-color; } } @@ -205,6 +207,10 @@ limitations under the License. .mx_UserMenu_iconSignOut::before { mask-image: url("$(res)/img/element-icons/leave.svg"); } + + .mx_UserMenu_iconQr::before { + mask-image: url("@vector-im/compound-design-tokens/icons/qr-code.svg"); + } } .mx_UserMenu_CustomStatusSection { diff --git a/res/css/structures/auth/_CompleteSecurity.pcss b/res/css/structures/auth/_CompleteSecurity.pcss index 4c3602ac264..ef8c82cbc83 100644 --- a/res/css/structures/auth/_CompleteSecurity.pcss +++ b/res/css/structures/auth/_CompleteSecurity.pcss @@ -35,8 +35,6 @@ limitations under the License. .mx_CompleteSecurity_skip { @mixin customisedCancelButton; - width: 18px; - height: 18px; position: absolute; right: 24px; } diff --git a/res/css/voice-broadcast/atoms/_PlaybackControlButton.pcss b/res/css/structures/auth/_ConfirmSessionLockTheftView.pcss similarity index 70% rename from res/css/voice-broadcast/atoms/_PlaybackControlButton.pcss rename to res/css/structures/auth/_ConfirmSessionLockTheftView.pcss index fd2c3ad73c5..14b92daaa81 100644 --- a/res/css/voice-broadcast/atoms/_PlaybackControlButton.pcss +++ b/res/css/structures/auth/_ConfirmSessionLockTheftView.pcss @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2019-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,13 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_BroadcastPlaybackControlButton { - align-items: center; - background-color: $background; - border-radius: 50%; +.mx_ConfirmSessionLockTheftView { + width: 100%; + height: 100%; display: flex; - height: 32px; + align-items: center; justify-content: center; - margin-bottom: $spacing-8; - width: 32px; +} + +.mx_ConfirmSessionLockTheftView_body { + display: flex; + flex-direction: column; + max-width: 400px; + align-items: center; } diff --git a/res/css/structures/auth/_Login.pcss b/res/css/structures/auth/_Login.pcss index 2eba8cf3d14..aa4244bcfbd 100644 --- a/res/css/structures/auth/_Login.pcss +++ b/res/css/structures/auth/_Login.pcss @@ -18,7 +18,7 @@ limitations under the License. .mx_Login_submit { @mixin mx_DialogButton; font-size: 15px; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); width: 100%; margin-top: 24px; margin-bottom: 24px; @@ -99,3 +99,8 @@ div.mx_AccessibleButton_kind_link.mx_Login_forgot { align-content: center; padding: 14px; } + +.mx_Login_fullWidthButton { + width: 100%; + margin-bottom: 16px; +} diff --git a/res/css/structures/auth/_LoginSplashView.pcss b/res/css/structures/auth/_LoginSplashView.pcss new file mode 100644 index 00000000000..fc9433782d9 --- /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/structures/auth/_Registration.pcss b/res/css/structures/auth/_Registration.pcss index b415e78f107..42ac7c0fb4e 100644 --- a/res/css/structures/auth/_Registration.pcss +++ b/res/css/structures/auth/_Registration.pcss @@ -21,7 +21,7 @@ limitations under the License. min-height: 270px; p { - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); color: $authpage-primary-color; &.secondary { diff --git a/res/css/structures/auth/_SessionLockStolenView.pcss b/res/css/structures/auth/_SessionLockStolenView.pcss new file mode 100644 index 00000000000..e9ab0d95ffa --- /dev/null +++ b/res/css/structures/auth/_SessionLockStolenView.pcss @@ -0,0 +1,30 @@ +/* +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. +*/ + +.mx_SessionLockStolenView { + h1 { + font-weight: var(--cpd-font-weight-semibold); + font-size: $font-32px; + text-align: center; + } + + h2 { + margin: 0; + font-weight: 500; + font-size: $font-24px; + text-align: center; + } +} diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index b5736e644dd..770438e9729 100644 --- a/res/css/views/auth/_AuthBody.pcss +++ b/res/css/views/auth/_AuthBody.pcss @@ -25,7 +25,7 @@ limitations under the License. box-sizing: border-box; b { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); } &.mx_AuthBody_flex { @@ -35,14 +35,13 @@ limitations under the License. h1 { font-size: $font-24px; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); margin-top: $spacing-8; color: $authpage-primary-color; } h2 { - font-size: $font-14px; - font-weight: var(--font-semi-bold); + font: var(--cpd-font-body-md-semibold); color: $authpage-secondary-color; } @@ -68,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 { @@ -141,7 +140,7 @@ limitations under the License. /* specialisation for password reset views */ .mx_AuthBody.mx_AuthBody_forgot-password { - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); color: $primary-content; padding: 50px 32px; min-height: 600px; @@ -156,7 +155,7 @@ limitations under the License. } .mx_Login_submit { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); margin: 0 0 $spacing-16; } @@ -169,7 +168,7 @@ limitations under the License. } .mx_AuthBody_sign-in-instead-button { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); padding: $spacing-4; } @@ -212,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; } @@ -263,7 +262,7 @@ limitations under the License. text-align: center; > a { - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); } } diff --git a/res/css/views/auth/_AuthFooter.pcss b/res/css/views/auth/_AuthFooter.pcss index 0bc2743d544..36349594ecd 100644 --- a/res/css/views/auth/_AuthFooter.pcss +++ b/res/css/views/auth/_AuthFooter.pcss @@ -17,7 +17,7 @@ limitations under the License. .mx_AuthFooter { text-align: center; width: 100%; - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); opacity: 0.72; padding: 20px 0; background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.8)); diff --git a/res/css/views/auth/_CompleteSecurityBody.pcss b/res/css/views/auth/_CompleteSecurityBody.pcss index 644a9a2d375..53d5988c6dd 100644 --- a/res/css/views/auth/_CompleteSecurityBody.pcss +++ b/res/css/views/auth/_CompleteSecurityBody.pcss @@ -25,13 +25,12 @@ limitations under the License. h2 { font-size: $font-24px; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); margin-top: 0; } h3 { - font-size: $font-14px; - font-weight: var(--font-semi-bold); + font: var(--cpd-font-body-md-semibold); } a:link, diff --git a/res/css/views/auth/_LanguageSelector.pcss b/res/css/views/auth/_LanguageSelector.pcss index 8a762e0de3c..b2e179d000c 100644 --- a/res/css/views/auth/_LanguageSelector.pcss +++ b/res/css/views/auth/_LanguageSelector.pcss @@ -20,8 +20,7 @@ limitations under the License. .mx_AuthBody_language .mx_Dropdown_input { border: none; - font-size: $font-14px; - font-weight: var(--font-semi-bold); + font: var(--cpd-font-body-md-semibold); color: $authpage-lang-color; width: auto; } diff --git a/res/css/views/auth/_LoginWithQR.pcss b/res/css/views/auth/_LoginWithQR.pcss index 699d7b0f38e..c4904952b6c 100644 --- a/res/css/views/auth/_LoginWithQR.pcss +++ b/res/css/views/auth/_LoginWithQR.pcss @@ -14,7 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_LoginWithQRSection .mx_AccessibleButton { +.mx_LoginWithQRSection p { + margin-top: 0; + margin-bottom: $spacing-16; +} + +.mx_LoginWithQRSection .mx_AccessibleButton svg { margin-right: $spacing-12; } @@ -27,56 +32,25 @@ limitations under the License. margin-top: $spacing-8; } - .mx_LoginWithQR_separator { - display: flex; - align-items: center; - text-align: center; - - &::before, - &::after { - content: ""; - flex: 1; - border-bottom: 1px solid $quinary-content; - } - - &:not(:empty) { - &::before { - margin-right: 1em; - } - &::after { - margin-left: 1em; - } - } - } - font-size: $font-15px; } .mx_UserSettingsDialog .mx_LoginWithQR { - .mx_AccessibleButton + .mx_AccessibleButton { - margin-left: $spacing-12; - } - - font-size: $font-14px; + font: var(--cpd-font-body-md-regular); h1 { font-size: $font-24px; margin-bottom: 0; } - li { - line-height: 1.8; + h2 { + margin-top: $spacing-24; } .mx_QRCode { - padding: $spacing-12 $spacing-40; margin: $spacing-28 0; } - .mx_LoginWithQR_buttons { - text-align: center; - } - .mx_LoginWithQR_qrWrapper { display: flex; } @@ -87,12 +61,6 @@ limitations under the License. display: flex; flex-direction: column; - .mx_LoginWithQR_centreTitle { - h1 { - text-align: centre; - } - } - h1 > svg { &.normal { color: $secondary-content; @@ -111,7 +79,7 @@ limitations under the License. .mx_LoginWithQR_confirmationDigits { text-align: center; margin: $spacing-48 auto; - font-weight: var(--font-semi-bold); + font-weight: var(--cpd-font-weight-semibold); font-size: $font-24px; color: $primary-content; } @@ -133,30 +101,127 @@ limitations under the License. } ol { - list-style-position: inside; padding-inline-start: 0; + list-style: none; /* list markers do not support the outlined number styling we need */ + + li { + position: relative; + padding-left: var(--cpd-space-7x); + color: 1px solid $input-placeholder; + margin-bottom: var(--cpd-space-4x); + line-height: 20px; + text-align: initial; + } - li::marker { - color: $accent; + /* Circled number list item marker */ + li::before { + content: counter(list-item); + position: absolute; + left: 0; + display: inline-block; + width: 20px; + height: 20px; + line-height: 20px; + border-radius: 50%; + border: 1px solid $input-placeholder; + box-sizing: border-box; + text-align: center; + } + } + + label[for="mx_LoginWithQR_checkCode"] { + margin-top: var(--cpd-space-6x); + color: var(--cpd-color-text-primary); + margin-bottom: var(--cpd-space-1x); + } + + .mx_LoginWithQR_icon { + width: 56px; + height: 56px; + border-radius: 8px; + box-sizing: border-box; + padding: var(--cpd-space-3x); + gap: 10px; + + background-color: var(--cpd-color-bg-success-subtle); + svg { + color: var(--cpd-color-icon-success-primary); + } + + &.mx_LoginWithQR_icon--critical { + background-color: var(--cpd-color-bg-critical-subtle); + svg { + color: var(--cpd-color-icon-critical-primary); + } + } + } + + .mx_LoginWithQR_checkCode_input { + margin-bottom: var(--cpd-space-1x); + text-align: initial; + + input { + /* Workaround for one of the input rules in _common.pcss being not specific enough */ + padding: 0; + padding-inline-start: calc(40px / 2 - (1ch / 2)); } } + .mx_LoginWithQR_heading { + display: flex; + gap: $spacing-12; + align-items: center; + } + .mx_LoginWithQR_BackButton { - height: $spacing-12; - margin-bottom: $spacing-24; + height: $spacing-28; + border-radius: $spacing-28; + padding: $spacing-4; + box-sizing: border-box; + background-color: var(--cpd-color-bg-subtle-secondary); svg { height: 100%; } } + .mx_LoginWithQR_breadcrumbs { + font-size: $font-13px; + color: $secondary-content; + } + .mx_LoginWithQR_main { display: flex; flex-direction: column; flex-grow: 1; + align-items: center; + color: $primary-content; + text-align: center; + + p { + color: $secondary-content; + } + } + + &.mx_LoginWithQR_error .mx_LoginWithQR_main { + max-width: 400px; + margin: 0 auto; + } + + .mx_LoginWithQR_buttons { + display: flex; + flex-direction: column; + align-items: center; + gap: $spacing-16; + margin-top: var(--cpd-space-6x); + + .mx_AccessibleButton { + width: 300px; + height: 48px; + box-sizing: border-box; + } } .mx_QRCode { - border: 1px solid $quinary-content; border-radius: $spacing-8; display: flex; justify-content: center; diff --git a/res/css/views/avatars/_BaseAvatar.pcss b/res/css/views/avatars/_BaseAvatar.pcss index 70c41d0b251..07415069d35 100644 --- a/res/css/views/avatars/_BaseAvatar.pcss +++ b/res/css/views/avatars/_BaseAvatar.pcss @@ -14,57 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_BaseAvatar { - position: relative; - /* In at least Firefox, the case of relative positioned inline elements */ - /* (such as mx_BaseAvatar) with absolute positioned children (such as */ - /* mx_BaseAvatar_initial) is a dark corner full of spider webs. It will give */ - /* different results during full reflow of the page vs. incremental reflow */ - /* of small portions. While that's surely a browser bug, we can avoid it by */ - /* using `inline-block` instead of the default `inline`. */ - /* https://github.com/vector-im/element-web/issues/5594 */ - /* https://bugzilla.mozilla.org/show_bug.cgi?id=1535053 */ - /* https://bugzilla.mozilla.org/show_bug.cgi?id=255139 */ - display: inline-block; - user-select: none; - - &.mx_RoomAvatar_isSpaceRoom { - &.mx_BaseAvatar_image, - .mx_BaseAvatar_image { - border-radius: 8px; - } - } -} - -.mx_BaseAvatar_initial { - position: absolute; - left: 0; - color: $avatar-initial-color; - text-align: center; - speak: none; - pointer-events: none; - font-weight: normal; -} - -.mx_BaseAvatar_image { - object-fit: cover; - aspect-ratio: 1; - border-radius: 125px; - vertical-align: top; - background-color: $background; -} - -/* Percy screenshot test specific CSS */ -@media only percy { - /* Stick the default room avatar colour, so it doesn't cause a false diff on the screenshot */ - .mx_BaseAvatar_initial { - background-color: var(--percy-color-avatar) !important; - border-radius: 125px; - } - .mx_RoomAvatar_isSpaceRoom .mx_BaseAvatar_initial { - border-radius: 8px; - } - .mx_BaseAvatar_initial + .mx_BaseAvatar_image { - visibility: hidden; - } +button.mx_BaseAvatar { + /*